diff --git a/src/StoreApi/Routes/V1/Products.php b/src/StoreApi/Routes/V1/Products.php index 809b7f83951..0cea8717f49 100644 --- a/src/StoreApi/Routes/V1/Products.php +++ b/src/StoreApi/Routes/V1/Products.php @@ -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', diff --git a/src/StoreApi/Routes/V1/ProductsBySlug.php b/src/StoreApi/Routes/V1/ProductsBySlug.php new file mode 100644 index 00000000000..8dc21b4bffe --- /dev/null +++ b/src/StoreApi/Routes/V1/ProductsBySlug.php @@ -0,0 +1,117 @@ +[\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 ); + } +} diff --git a/src/StoreApi/RoutesController.php b/src/StoreApi/RoutesController.php index 4ef2eab6183..040ab0ec153 100644 --- a/src/StoreApi/RoutesController.php +++ b/src/StoreApi/RoutesController.php @@ -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, ], ]; } diff --git a/src/StoreApi/Schemas/V1/ProductSchema.php b/src/StoreApi/Schemas/V1/ProductSchema.php index 7004c5333ae..9d7935b9d7b 100644 --- a/src/StoreApi/Schemas/V1/ProductSchema.php +++ b/src/StoreApi/Schemas/V1/ProductSchema.php @@ -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', @@ -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 ) : '' ), diff --git a/src/StoreApi/Utilities/ProductQuery.php b/src/StoreApi/Utilities/ProductQuery.php index bdb4262e947..358e8351255 100644 --- a/src/StoreApi/Utilities/ProductQuery.php +++ b/src/StoreApi/Utilities/ProductQuery.php @@ -27,6 +27,7 @@ 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', @@ -34,8 +35,8 @@ public function prepare_objects_query( $request ) { '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' ]; } @@ -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 ) { @@ -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' ) ) ) . '")'; diff --git a/src/StoreApi/docs/products.md b/src/StoreApi/docs/products.md index 9cfabe0ae8e..c0ad1450b1c 100644 --- a/src/StoreApi/docs/products.md +++ b/src/StoreApi/docs/products.md @@ -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 @@ -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` | @@ -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", @@ -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 @@ -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": "

This is an external product.

", + "short_description": "

This is an external product.

", + "description": "

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.

", + "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 “WordPress Pennant” 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", diff --git a/tests/php/StoreApi/Routes/Products.php b/tests/php/StoreApi/Routes/Products.php index d0853af61f1..a0ebe08f0d8 100644 --- a/tests/php/StoreApi/Routes/Products.php +++ b/tests/php/StoreApi/Routes/Products.php @@ -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 ) );