Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Store API: allow searching by slug and include slug in the respon…
Browse files Browse the repository at this point in the history
…se (#9017)

* Add ProductBySlug, search by slug and return slug field for products

* Search for product variation

* Add slug to tests

* Sanitize the slug
  • Loading branch information
albarin authored Apr 14, 2023
1 parent 3770313 commit d4c912c
Show file tree
Hide file tree
Showing 7 changed files with 221 additions and 6 deletions.
7 changes: 7 additions & 0 deletions src/StoreApi/Routes/V1/Products.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ public function get_collection_params() {
'validate_callback' => 'rest_validate_request_arg',
);

$params['slug'] = array(
'description' => __( 'Limit result set to products with specific slug(s). Use commas to separate.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
);

$params['after'] = array(
'description' => __( 'Limit response to resources created after a given ISO8601 compliant date.', 'woo-gutenberg-products-block' ),
'type' => 'string',
Expand Down
117 changes: 117 additions & 0 deletions src/StoreApi/Routes/V1/ProductsBySlug.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;

use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;

/**
* ProductsBySlug class.
*/
class ProductsBySlug extends AbstractRoute {
/**
* The route identifier.
*
* @var string
*/
const IDENTIFIER = 'products-by-slug';

/**
* The routes schema.
*
* @var string
*/
const SCHEMA_TYPE = 'product';

/**
* Get the path of this REST route.
*
* @return string
*/
public function get_path() {
return '/products/(?P<slug>[\S]+)';
}

/**
* Get method arguments for this REST route.
*
* @return array An array of endpoints.
*/
public function get_args() {
return [
'args' => array(
'slug' => array(
'description' => __( 'Slug of the resource.', 'woo-gutenberg-products-block' ),
'type' => 'string',
),
),
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'get_response' ],
'permission_callback' => '__return_true',
'args' => array(
'context' => $this->get_context_param(
array(
'default' => 'view',
)
),
),
],
'schema' => [ $this->schema, 'get_public_item_schema' ],
];
}

/**
* Get a single item.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response
*/
protected function get_route_response( \WP_REST_Request $request ) {
$slug = sanitize_title( $request['slug'] );

$object = $this->get_product_by_slug( $slug );
if ( ! $object ) {
$object = $this->get_product_variation_by_slug( $slug );
}

if ( ! $object || 0 === $object->get_id() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woo-gutenberg-products-block' ), 404 );
}

return rest_ensure_response( $this->schema->get_item_response( $object ) );
}

/**
* Get a product by slug.
*
* @param string $slug The slug of the product.
*/
public function get_product_by_slug( $slug ) {
return wc_get_product( get_page_by_path( $slug, OBJECT, 'product' ) );
}

/**
* Get a product variation by slug.
*
* @param string $slug The slug of the product variation.
*/
private function get_product_variation_by_slug( $slug ) {
global $wpdb;

$result = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_name, post_parent, post_type
FROM $wpdb->posts
WHERE post_name = %s
AND post_type = 'product_variation'",
$slug
)
);

if ( ! $result ) {
return null;
}

return wc_get_product( $result[0]->ID );
}
}
1 change: 1 addition & 0 deletions src/StoreApi/RoutesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public function __construct( SchemaController $schema_controller ) {
Routes\V1\ProductTags::IDENTIFIER => Routes\V1\ProductTags::class,
Routes\V1\Products::IDENTIFIER => Routes\V1\Products::class,
Routes\V1\ProductsById::IDENTIFIER => Routes\V1\ProductsById::class,
Routes\V1\ProductsBySlug::IDENTIFIER => Routes\V1\ProductsBySlug::class,
],
];
}
Expand Down
6 changes: 6 additions & 0 deletions src/StoreApi/Schemas/V1/ProductSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ public function get_properties() {
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'slug' => [
'description' => __( 'Product slug.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
],
'parent' => [
'description' => __( 'ID of the parent product, if applicable.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
Expand Down Expand Up @@ -449,6 +454,7 @@ public function get_item_response( $product ) {
return [
'id' => $product->get_id(),
'name' => $this->prepare_html_response( $product->get_title() ),
'slug' => $product->get_slug(),
'parent' => $product->get_parent_id(),
'type' => $product->get_type(),
'variation' => $this->prepare_html_response( $product->is_type( 'variation' ) ? wc_get_formatted_variation( $product, true, true, false ) : '' ),
Expand Down
18 changes: 15 additions & 3 deletions src/StoreApi/Utilities/ProductQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@ public function prepare_objects_query( $request ) {
'post_parent__in' => $request['parent'],
'post_parent__not_in' => $request['parent_exclude'],
'search' => $request['search'], // This uses search rather than s intentionally to handle searches internally.
'slug' => $request['slug'],
'fields' => 'ids',
'ignore_sticky_posts' => true,
'post_status' => 'publish',
'date_query' => [],
'post_type' => 'product',
];

// If searching for a specific SKU, allow any post type.
if ( ! empty( $request['sku'] ) ) {
// If searching for a specific SKU or slug, allow any post type.
if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) {
$args['post_type'] = [ 'product', 'product_variation' ];
}

Expand Down Expand Up @@ -96,7 +97,7 @@ public function prepare_objects_query( $request ) {
];

// Gets all registered product taxonomies and prefixes them with `tax_`.
// This is neeeded to avoid situations where a users registers a new product taxonomy with the same name as default field.
// This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field.
// eg an `sku` taxonomy will be mapped to `tax_sku`.
$all_product_taxonomies = array_map(
function ( $value ) {
Expand Down Expand Up @@ -326,6 +327,17 @@ public function add_query_clauses( $args, $wp_query ) {
$args['where'] .= ' AND wc_product_meta_lookup.sku IN ("' . implode( '","', array_map( 'esc_sql', $skus ) ) . '")';
}

if ( $wp_query->get( 'slug' ) ) {
$slugs = explode( ',', $wp_query->get( 'slug' ) );
// Include the current string as a slug too.
if ( 1 < count( $slugs ) ) {
$slugs[] = $wp_query->get( 'slug' );
}
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$post_name__in = implode( '","', array_map( 'esc_sql', $slugs ) );
$args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")";
}

if ( $wp_query->get( 'stock_status' ) ) {
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.stock_status IN ("' . implode( '","', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '")';
Expand Down
77 changes: 74 additions & 3 deletions src/StoreApi/docs/products.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The store products API provides public product data so it can be rendered on the
```http
GET /products
GET /products?search=product%20name
GET /products?slug=slug-1,slug-2
GET /products?after=2017-03-22&date_column=date
GET /products?before=2017-03-22&date_column=date
GET /products?exclude=10,44,33
Expand Down Expand Up @@ -39,8 +40,9 @@ GET /products?return_rating_counts=true
```

| Attribute | Type | Required | Description |
| :------------------------------------------ | :------ | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|:--------------------------------------------|:--------| :------: |:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `search` | integer | no | Limit results to those matching a string. |
| `slug` | string | no | Limit result set to products with specific slug(s). Use commas to separate. |
| `after` | string | no | Limit response to resources created after a given ISO8601 compliant date. |
| `before` | string | no | Limit response to resources created before a given ISO8601 compliant date. |
| `date_column` | string | no | When limiting response using after/before, which date column to compare against. Allowed values: `date`, `date_gmt`, `modified`, `modified_gmt` |
Expand Down Expand Up @@ -80,6 +82,7 @@ curl "https://example-store.com/wp-json/wc/store/v1/products"
{
"id": 34,
"name": "WordPress Pennant",
"slug": "wordpress-pennant",
"variation": "",
"permalink": "https://local.wordpress.test/product/wordpress-pennant/",
"sku": "wp-pennant",
Expand Down Expand Up @@ -125,9 +128,9 @@ curl "https://example-store.com/wp-json/wc/store/v1/products"
]
```

## Single Product
## Single Product by ID

Get a single product.
Get a single product by id.

```http
GET /products/:id
Expand All @@ -147,6 +150,74 @@ curl "https://example-store.com/wp-json/wc/store/v1/products/34"
{
"id": 34,
"name": "WordPress Pennant",
"slug": "wordpress-pennant",
"variation": "",
"permalink": "https://local.wordpress.test/product/wordpress-pennant/",
"sku": "wp-pennant",
"summary": "<p>This is an external product.</p>",
"short_description": "<p>This is an external product.</p>",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>",
"on_sale": false,
"prices": {
"currency_code": "GBP",
"currency_symbol": "£",
"currency_minor_unit": 2,
"currency_decimal_separator": ".",
"currency_thousand_separator": ",",
"currency_prefix": "£",
"currency_suffix": "",
"price": "1105",
"regular_price": "1105",
"sale_price": "1105",
"price_range": null
},
"average_rating": "0",
"review_count": 0,
"images": [
{
"id": 57,
"src": "https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1.jpg",
"thumbnail": "https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-324x324.jpg",
"srcset": "https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1.jpg 800w, https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-324x324.jpg 324w, https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-100x100.jpg 100w, https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-416x416.jpg 416w, https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-300x300.jpg 300w, https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-150x150.jpg 150w, https://local.wordpress.test/wp-content/uploads/2020/03/pennant-1-768x768.jpg 768w",
"sizes": "(max-width: 800px) 100vw, 800px",
"name": "pennant-1.jpg",
"alt": ""
}
],
"has_options": false,
"is_purchasable": true,
"is_in_stock": true,
"low_stock_remaining": null,
"add_to_cart": {
"text": "Add to cart",
"description": "Add &ldquo;WordPress Pennant&rdquo; to your cart"
}
}
```

## Single Product by slug

Get a single product by slug.

```http
GET /products/:slug
```

| Attribute | Type | Required | Description |
|:----------|:-------| :------: |:-------------------------------------|
| `slug` | string | Yes | The slug of the product to retrieve. |

```sh
curl "https://example-store.com/wp-json/wc/store/v1/products/wordpress-pennant"
```

**Example response:**

```json
{
"id": 34,
"name": "WordPress Pennant",
"slug": "wordpress-pennant",
"variation": "",
"permalink": "https://local.wordpress.test/product/wordpress-pennant/",
"sku": "wp-pennant",
Expand Down
1 change: 1 addition & 0 deletions tests/php/StoreApi/Routes/Products.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public function test_get_item() {
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( $this->products[0]->get_id(), $data['id'] );
$this->assertEquals( $this->products[0]->get_title(), $data['name'] );
$this->assertEquals( $this->products[0]->get_slug(), $data['slug'] );
$this->assertEquals( $this->products[0]->get_permalink(), $data['permalink'] );
$this->assertEquals( $this->products[0]->get_sku(), $data['sku'] );
$this->assertEquals( $this->products[0]->get_price(), $data['prices']->price / ( 10 ** $data['prices']->currency_minor_unit ) );
Expand Down

0 comments on commit d4c912c

Please sign in to comment.