From 9d79403db23100ff5863f36729249f66259f84f8 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Fri, 25 Oct 2019 10:43:52 +0100 Subject: [PATCH] REST API - Store API - Product filtering data, products endpoint, and cart refinements (https://github.com/woocommerce/woocommerce-blocks/pull/1055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cart add endpoint and schema * Empty card DELETE method * Merge into single controller * Revise verb usage * PUT/update requests * Move under rest api namespace * Basic test coverage * Invalid tests with status check * Variation handling * Update src/RestApi/StoreApi/Schemas/CartItemSchema.php Co-Authored-By: Albert Juhé Lluveras * Remove key arg for delete endpoint * code comment for creation * rename param to product_id * Renaming methods from _item to _cart_item * Prepare storeAPI products endpoint for public use * Price filter headers * Attribute counts * Add Rating filter * Rating counts * Fix counts * Moved utilities * API docs * Use correct response for cart item * Attributes filtering * Stats * Products/Stats unit tests * Rename stats to collection data * Remove `embed` from schema * Add since $VID:$ tags * Improve operator logic and add isset checks * Force cart schema to be readonly --- plugins/woocommerce-blocks/src/RestApi.php | 20 +- .../src/RestApi/StoreApi/Controllers/Cart.php | 12 +- .../StoreApi/Controllers/CartItems.php | 18 +- .../Controllers/ProductCollectionData.php | 210 +++++++ .../RestApi/StoreApi/Controllers/Products.php | 475 ++++++++++++++++ .../src/RestApi/StoreApi/README.md | 538 +++++++++++++++++- .../StoreApi/Schemas/AbstractSchema.php | 21 + .../StoreApi/Schemas/CartItemSchema.php | 2 + .../RestApi/StoreApi/Schemas/CartSchema.php | 4 +- .../StoreApi/Schemas/ProductSchema.php | 146 +++++ .../StoreApi/Utilities/CartController.php | 2 + .../RestApi/StoreApi/Utilities/Pagination.php | 81 +++ .../StoreApi/Utilities/ProductFiltering.php | 142 +++++ .../StoreApi/Utilities/ProductQuery.php | 349 ++++++++++++ .../php/RestApi/StoreApi/Controllers/Cart.php | 4 - .../StoreApi/Controllers/CartItems.php | 10 +- .../Controllers/ProductCollectionData.php | 172 ++++++ .../RestApi/StoreApi/Controllers/Products.php | 158 +++++ 18 files changed, 2321 insertions(+), 43 deletions(-) create mode 100644 plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php create mode 100644 plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Products.php create mode 100644 plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php create mode 100644 plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/Pagination.php create mode 100644 plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php create mode 100644 plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductQuery.php create mode 100644 plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php create mode 100644 plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Products.php diff --git a/plugins/woocommerce-blocks/src/RestApi.php b/plugins/woocommerce-blocks/src/RestApi.php index bb6fa357c7c..a2878ada54e 100644 --- a/plugins/woocommerce-blocks/src/RestApi.php +++ b/plugins/woocommerce-blocks/src/RestApi.php @@ -62,15 +62,17 @@ class RestApi { */ protected static function get_controllers() { return [ - 'product-attributes' => __NAMESPACE__ . '\RestApi\Controllers\ProductAttributes', - 'product-attribute-terms' => __NAMESPACE__ . '\RestApi\Controllers\ProductAttributeTerms', - 'product-categories' => __NAMESPACE__ . '\RestApi\Controllers\ProductCategories', - 'product-tags' => __NAMESPACE__ . '\RestApi\Controllers\ProductTags', - 'products' => __NAMESPACE__ . '\RestApi\Controllers\Products', - 'variations' => __NAMESPACE__ . '\RestApi\Controllers\Variations', - 'product-reviews' => __NAMESPACE__ . '\RestApi\Controllers\ProductReviews', - 'store-cart' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Cart', - 'store-cart-items' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartItems', + 'product-attributes' => __NAMESPACE__ . '\RestApi\Controllers\ProductAttributes', + 'product-attribute-terms' => __NAMESPACE__ . '\RestApi\Controllers\ProductAttributeTerms', + 'product-categories' => __NAMESPACE__ . '\RestApi\Controllers\ProductCategories', + 'product-tags' => __NAMESPACE__ . '\RestApi\Controllers\ProductTags', + 'products' => __NAMESPACE__ . '\RestApi\Controllers\Products', + 'variations' => __NAMESPACE__ . '\RestApi\Controllers\Variations', + 'product-reviews' => __NAMESPACE__ . '\RestApi\Controllers\ProductReviews', + 'store-cart' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Cart', + 'store-cart-items' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\CartItems', + 'store-products' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\Products', + 'store-product-collection-data' => __NAMESPACE__ . '\RestApi\StoreApi\Controllers\ProductCollectionData', ]; } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php index 58842b44a66..edf48b879f2 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Cart.php @@ -18,6 +18,8 @@ use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController; /** * Cart API. + * + * @since $VID:$ */ class Cart extends RestContoller { /** @@ -37,15 +39,15 @@ class Cart extends RestContoller { /** * Schema class instance. * - * @var array + * @var object */ - protected $cart_schema; + protected $schema; /** * Setup API class. */ public function __construct() { - $this->cart_schema = new CartSchema(); + $this->schema = new CartSchema(); } /** @@ -94,7 +96,7 @@ class Cart extends RestContoller { * @return array */ public function get_item_schema() { - return $this->cart_schema->get_item_schema(); + return $this->schema->get_item_schema(); } /** @@ -105,7 +107,7 @@ class Cart extends RestContoller { * @return \WP_REST_Response Response object. */ public function prepare_item_for_response( $cart, $request ) { - $data = $this->cart_schema->get_item_response( $cart ); + $data = $this->schema->get_item_response( $cart ); return rest_ensure_response( $data ); } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartItems.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartItems.php index 050c2770e43..03a7d9df0a8 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartItems.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/CartItems.php @@ -19,6 +19,8 @@ use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\CartController; /** * Cart API. + * + * @since $VID:$ */ class CartItems extends RestContoller { /** @@ -36,17 +38,17 @@ class CartItems extends RestContoller { protected $rest_base = 'cart/items'; /** - * Schema class instances. + * Schema class instance. * - * @var array + * @var object */ - protected $cart_item_schema; + protected $schema; /** * Setup API class. */ public function __construct() { - $this->cart_item_schema = new CartItemSchema(); + $this->schema = new CartItemSchema(); } /** @@ -173,7 +175,9 @@ class CartItems extends RestContoller { return $result; } - return rest_ensure_response( $this->prepare_item_for_response( $controller->get_cart_item( $result ), $request ) ); + $response = rest_ensure_response( $this->prepare_item_for_response( $controller->get_cart_item( $result ), $request ) ); + $response->set_status( 201 ); + return $response; } /** @@ -237,7 +241,7 @@ class CartItems extends RestContoller { * @return array */ public function get_item_schema() { - return $this->cart_item_schema->get_item_schema(); + return $this->schema->get_item_schema(); } /** @@ -248,7 +252,7 @@ class CartItems extends RestContoller { * @return \WP_REST_Response Response object. */ public function prepare_item_for_response( $cart_item, $request ) { - $data = $this->cart_item_schema->get_item_response( $cart_item ); + $data = $this->schema->get_item_response( $cart_item ); return rest_ensure_response( $data ); } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php new file mode 100644 index 00000000000..b38e5213445 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/ProductCollectionData.php @@ -0,0 +1,210 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => RestServer::READABLE, + 'callback' => array( $this, 'get_items' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Return the schema. + * + * @return array + */ + public function get_item_schema() { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'product_collection_data', + 'type' => 'object', + 'properties' => [ + 'min_price' => array( + 'description' => __( 'Min price found in collection of products.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'max_price' => array( + 'description' => __( 'Max price found in collection of products.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'attribute_counts' => array( + 'description' => __( 'Returns number of products within attribute terms, indexed by term ID.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'term' => array( + 'description' => __( 'Term ID', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'count' => array( + 'description' => __( 'Number of products.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + ), + ), + 'rating_counts' => array( + 'description' => __( 'Returns number of products with each average rating.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'rating' => array( + 'description' => __( 'Average rating', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'count' => array( + 'description' => __( 'Number of products.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + ), + ), + ], + ]; + } + + /** + * Get a collection of posts and add the post title filter option to \WP_Query. + * + * @param \WP_REST_Request $request Full details about the request. + * @return RestError|\WP_REST_Response + */ + public function get_items( $request ) { + $return = [ + 'min_price' => null, + 'max_price' => null, + 'attribute_counts' => null, + 'rating_counts' => null, + ]; + $filters = new ProductQueryFilters(); + + if ( ! empty( $request['calculate_price_range'] ) ) { + $price_results = $filters->get_filtered_price( $request ); + + $return['min_price'] = $price_results->min_price; + $return['max_price'] = $price_results->max_price; + } + + if ( ! empty( $request['calculate_attribute_counts'] ) ) { + $return['attribute_counts'] = []; + $counts = $filters->get_attribute_counts( $request, $request['calculate_attribute_counts'] ); + + foreach ( $counts as $key => $value ) { + $return['attribute_counts'][] = [ + 'term' => $key, + 'count' => $value, + ]; + } + } + + if ( ! empty( $request['calculate_rating_counts'] ) ) { + $return['rating_counts'] = []; + $counts = $filters->get_rating_counts( $request ); + + foreach ( $counts as $key => $value ) { + $return['rating_counts'][] = [ + 'rating' => $key, + 'count' => $value, + ]; + } + } + + return rest_ensure_response( $return ); + } + + /** + * Get the query params for collections of products. + * + * @return array + */ + public function get_collection_params() { + $params = ( new Products() )->get_collection_params(); + + $params['calculate_price_range'] = array( + 'description' => __( 'If true, calculates the minimum and maximum product prices for the collection.', 'woo-gutenberg-products-block' ), + 'type' => 'boolean', + 'default' => false, + ); + + $params['calculate_attribute_counts'] = array( + 'description' => __( 'If requested, calculates attribute term counts for products in the collection.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'default' => array(), + ); + + $params['calculate_rating_counts'] = array( + 'description' => __( 'If true, calculates rating counts for products in the collection.', 'woo-gutenberg-products-block' ), + 'type' => 'boolean', + 'default' => false, + ); + + return $params; + } +} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Products.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Products.php new file mode 100644 index 00000000000..5aef79be551 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Controllers/Products.php @@ -0,0 +1,475 @@ +schema = new ProductSchema(); + $this->product_query = new ProductQuery(); + } + + /** + * Register the routes for products. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => RestServer::READABLE, + 'callback' => [ $this, 'get_items' ], + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => RestServer::READABLE, + 'callback' => array( $this, 'get_item' ), + 'args' => array( + 'context' => $this->get_context_param( + array( + 'default' => 'view', + ) + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Product item schema. + * + * @return array + */ + public function get_item_schema() { + return $this->schema->get_item_schema(); + } + + /** + * Prepare a single item for response. + * + * @param \WC_Product $item Product object. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response $response Response data. + */ + public function prepare_item_for_response( $item, $request ) { + return rest_ensure_response( $this->schema->get_item_response( $item ) ); + } + + /** + * Get a single item. + * + * @param \WP_REST_Request $request Full details about the request. + * @return RestError|\WP_REST_Response + */ + public function get_item( $request ) { + $object = wc_get_product( (int) $request['id'] ); + + if ( ! $object || 0 === $object->get_id() ) { + return new RestError( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woo-gutenberg-products-block' ), array( 'status' => 404 ) ); + } + + $data = $this->prepare_item_for_response( $object, $request ); + $response = rest_ensure_response( $data ); + + return $response; + } + + /** + * Get a collection of posts and add the post title filter option to \WP_Query. + * + * @param \WP_REST_Request $request Full details about the request. + * @return RestError|\WP_REST_Response + */ + public function get_items( $request ) { + $query_results = $this->product_query->get_objects( $request ); + $objects = array(); + + foreach ( $query_results['objects'] as $object ) { + $data = $this->prepare_item_for_response( $object, $request ); + $objects[] = $this->prepare_response_for_collection( $data ); + } + + $total = $query_results['total']; + $max_pages = $query_results['pages']; + + $response = rest_ensure_response( $objects ); + $response = ( new Pagination() )->add_headers( $response, $request, $total, $max_pages ); + + return $response; + } + + /** + * Prepare links for the request. + * + * @param \WC_Product $item Product object. + * @param \WP_REST_Request $request Request object. + * @return array + */ + protected function prepare_links( $item, $request ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $item->get_id() ) ), // @codingStandardsIgnoreLine. + ), + 'collection' => array( + 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), // @codingStandardsIgnoreLine. + ), + ); + + if ( $item->get_parent_id() ) { + $links['up'] = array( + 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $item->get_parent_id() ) ), // @codingStandardsIgnoreLine. + ); + } + + return $links; + } + + /** + * Get the query params for collections of products. + * + * @return array + */ + public function get_collection_params() { + $params = array(); + $params['context'] = $this->get_context_param(); + $params['context']['default'] = 'view'; + + $params['page'] = array( + 'description' => __( 'Current page of the collection.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + 'minimum' => 1, + ); + + $params['per_page'] = array( + 'description' => __( 'Maximum number of items to be returned in result set.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['search'] = array( + 'description' => __( 'Limit results to those matching a string.', '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', + 'format' => 'date-time', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['before'] = array( + 'description' => __( 'Limit response to resources created before a given ISO8601 compliant date.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'format' => 'date-time', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['date_column'] = array( + 'description' => __( 'When limiting response using after/before, which date column to compare against.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'default' => 'date', + 'enum' => array( + 'date', + 'date_gmt', + 'modified', + 'modified_gmt', + ), + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['exclude'] = array( + 'description' => __( 'Ensure result set excludes specific IDs.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + 'sanitize_callback' => 'wp_parse_id_list', + ); + + $params['include'] = array( + 'description' => __( 'Limit result set to specific ids.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + 'sanitize_callback' => 'wp_parse_id_list', + ); + + $params['offset'] = array( + 'description' => __( 'Offset the result set by a specific number of items.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['order'] = array( + 'description' => __( 'Order sort attribute ascending or descending.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'default' => 'desc', + 'enum' => array( 'asc', 'desc' ), + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['orderby'] = array( + 'description' => __( 'Sort collection by object attribute.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'default' => 'date', + 'enum' => array( + 'date', + 'modified', + 'id', + 'include', + 'title', + 'slug', + 'price', + 'popularity', + 'rating', + 'menu_order', + 'comment_count', + ), + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['parent'] = array( + 'description' => __( 'Limit result set to those of particular parent IDs.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'sanitize_callback' => 'wp_parse_id_list', + 'default' => array(), + ); + + $params['parent_exclude'] = array( + 'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'sanitize_callback' => 'wp_parse_id_list', + 'default' => array(), + ); + + $params['type'] = array( + 'description' => __( 'Limit result set to products assigned a specific type.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array_keys( wc_get_product_types() ), + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['sku'] = array( + 'description' => __( 'Limit result set to products with specific SKU(s). Use commas to separate.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['featured'] = array( + 'description' => __( 'Limit result set to featured products.', 'woo-gutenberg-products-block' ), + 'type' => 'boolean', + 'sanitize_callback' => 'wc_string_to_bool', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['category'] = array( + 'description' => __( 'Limit result set to products assigned a specific category ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'sanitize_callback' => 'wp_parse_id_list', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['tag'] = array( + 'description' => __( 'Limit result set to products assigned a specific tag ID.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'sanitize_callback' => 'wp_parse_id_list', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['on_sale'] = array( + 'description' => __( 'Limit result set to products on sale.', 'woo-gutenberg-products-block' ), + 'type' => 'boolean', + 'sanitize_callback' => 'wc_string_to_bool', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['min_price'] = array( + 'description' => __( 'Limit result set to products based on a minimum price.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['max_price'] = array( + 'description' => __( 'Limit result set to products based on a maximum price.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['stock_status'] = array( + 'description' => __( 'Limit result set to products with specified stock status.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array_keys( wc_get_product_stock_status_options() ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['category_operator'] = array( + 'description' => __( 'Operator to compare product category terms.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array( 'in', 'not_in', 'and' ), + 'default' => 'in', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['tag_operator'] = array( + 'description' => __( 'Operator to compare product tags.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array( 'in', 'not_in', 'and' ), + 'default' => 'in', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['attribute_operator'] = array( + 'description' => __( 'Operator to compare product attribute terms.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array( 'in', 'not_in', 'and' ), + 'default' => 'in', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['attributes'] = array( + 'description' => __( 'Limit result set to products with selected global attributes.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'attribute' => array( + 'description' => __( 'Attribute taxonomy name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'sanitize_callback' => 'wc_sanitize_taxonomy_name', + ), + 'term_id' => array( + 'description' => __( 'Attribute term ID.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'sanitize_callback' => 'wp_parse_id_list', + ), + 'slug' => array( + 'description' => __( 'Comma separatede list of attribute slug(s). If a term ID is provided, this will be ignored.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'Operator to compare product attribute terms.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => [ 'in', 'not in', 'and' ], + ), + ), + ), + 'default' => array(), + ); + + $params['catalog_visibility'] = array( + 'description' => __( 'Determines if hidden or visible catalog products are shown.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'enum' => array( 'any', 'visible', 'catalog', 'search', 'hidden' ), + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $params['rating'] = array( + 'description' => __( 'Limit result set to products with a certain average rating.', 'woo-gutenberg-products-block' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + 'enum' => range( 1, 5 ), + ), + 'default' => array(), + 'sanitize_callback' => 'wp_parse_id_list', + ); + + return $params; + } +} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md b/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md index 3442606a1d3..39aa9a705cf 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/README.md @@ -1,15 +1,533 @@ # WooCommerce Store API -The WooCommerce Store API is a public-facing REST API that makes it possible to add items to cart, update the cart, and retrieve cart data in JSON format. +The WooCommerce Store API is a public-facing REST API; unlike the main WooCommerce REST API, this API does not require authentication. It is intended to be used by client side code to provide functionality to customers. -Unlike the main WooCommerce REST API, this API does not require authentication. It is intended to be used via AJAX and other client side code, such as the add-to-cart functionality in blocks, to provide functionality to customers. +Documentation in this readme file assumes knowledge of REST concepts. -## Endpoints +## Current status -- GET `/wc/store/cart` - Get a representation of the cart, including totals. -- GET `/wc/store/cart/items` - Get cart items. -- GET `/wc/store/cart/items/` - Get a single cart item. -- PUT `/wc/store/cart/items/` - Update a single cart item (quantity only). -- POST `/wc/store/cart/items` - Create a new cart item. -- DELETE `/wc/store/cart/items` - Delete all cart items (clear cart). -- DELETE `/wc/store/cart/items/` - Delete a single cart item. +This API is used internally by Blocks--it is still in flux and may be subject to revisions. There is currently no versioning system and this should be used at your own risk. Eventually, it will be moved to the main WooCommerce REST API at which point it will be versioned and safe to use in other projects. + +## Basic usage + +Example of a valid API request using cURL: + +```http +curl "https://example-store.com/wp-json/wc/store/products" +``` + +The API uses JSON to serialize data. You don’t need to specify `.json` at the end of an API URL. + +## Namespace + +Resources in the Store API are all found within the `wc/store/` namespace, and since this API extends the WordPress API, accessing it requires the `/wp-json/` base. Examples: + +```http +GET /wp-json/wc/store/products +GET /wp-json/wc/store/cart +``` + +## Authentication + +Requests to the store API do not require authentication. Only public data is returned, and most endpoints are read-only, with the exception of the cart API which only lets you manipulate data for the current user. + +## Status codes + +The following table gives an overview of how the API functions generally behave. + +| Request type | Description | +| :----------- | :---------------------------------------------------------------------------------------------------------- | +| `GET` | Access one or more resources and return `200 OK` and the result as JSON. | +| `POST` | Return `201 Created` if the resource is successfully created and return the newly created resource as JSON. | +| `PUT` | Return `200 OK` if the resource is modified successfully. The modified result is returned as JSON. | +| `DELETE` | Returns `204 No Content` if the resource was deleted successfully. | + +The following table shows the possible return codes for API requests. + +| Response code | Description | +| :----------------------- | :------------------------------------------------------------------------------------------------------------------------------ | +| `200 OK` | The request was successful, the resource(s) itself is returned as JSON. | +| `204 No Content` | The server has successfully fulfilled the request and that there is no additional content to send in the response payload body. | +| `201 Created` | The POST request was successful and the resource is returned as JSON. | +| `400 Bad Request` | A required attribute of the API request is missing. | | +| `403 Forbidden` | The request is not allowed. | +| `404 Not Found` | A resource could not be accessed, for example it doesn't exist. | +| `405 Method Not Allowed` | The request is not supported. | +| `500 Server Error` | While handling the request something went wrong server-side. | + +## Pagination + +If collections contain many results, they may be paginated. When listing resources you can pass the following parameters: + +| Parameter | Description | +| :--------- | :------------------------------------------------------------------------------------- | +| `page` | Current page of the collection. Defaults to `1`. | +| `per_page` | Maximum number of items to be returned in result set. Defaults to `10`. Maximum `100`. | + +In the example below, we list 20 products per page and return page 2. + +```http +curl "https://example-store.com/wp-json/wc/store/products?page=2&per_page=20" +``` + +### Pagination headers + +Additional pagination headers are also sent back. + +| Header | Description | +| :---------------- | :------------------------------------------------------------------------ | +| `X-WP-Total` | The total number of items in the collection. | +| `X-WP-TotalPages` | The total number of pages in the collection. | +| `Link` | Contains links to other pages; `next`, `prev`, and `up` where applicable. | + +## API resources + +Available resources in the Store API include: + +| Resource | Available endpoints | +| :--------------------------------------------------------- | :----------------------------------- | +| [`Product Collection Data`](#products-collection-data-api) | `/wc/store/products/collection-data` | +| [`Products`](#products-api) | `/wc/store/products` | +| [`Cart`](#cart-api) | `/wc/store/cart` | +| [`Cart Items`](#cart-items-api) | `/wc/store/cart/items` | + +## Product Collection Data API + +This endpoint allows you to get aggregate data from a collection of products, for example, the min and max price in a collection of products (ignoring pagination). This is used by blocks for product filtering widgets, since counts are based on the product catalog being viewed. + +```http +GET /products/collection-data +GET /products/collection-data?calculate_price_range=true +GET /products/collection-data?calculate_attribute_counts=pa_size,pa_color +GET /products/collection-data?calculate_rating_counts=true +``` + +| Attribute | Type | Required | Description | +| :--------------------------- | :----- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------- | +| `calculate_price_range` | bool | No | Returns the min and max price for the product collection. If false, only `null` will be returned. | +| `calculate_attribute_counts` | string | No | Returns attribute counts for a list of attribute (taxonomy) names you pass in via the parameter. If empty, only `null` will be returned. | +| `calculate_rating_counts` | bool | No | Returns the counts of products with a certain average rating, 1-5. If false, only `null` will be returned. | + +**In addition to the above attributes**, all product list attributes are supported. This allows you to get data for a certain subset of products. See [the products API list products section](#list-products) for the full list. + +```http +curl "https://example-store.com/wp-json/wc/store/products/collection-data?calculate_price_range=true&calculate_attribute_counts=pa_size,pa_color&calculate_rating_counts=true" +``` + +Example response: + +```json +{ + "min_price": "0.00", + "max_price": "90.00", + "attribute_counts": [ + { + "term": 22, + "count": 4 + }, + { + "term": 23, + "count": 3 + }, + { + "term": 24, + "count": 4 + } + ], + "rating_counts": [ + { + "rating": 3, + "count": 1 + }, + { + "rating": 4, + "count": 1 + } + ] +} +``` + +## Products API + +### List products + +```http +GET /products +GET /products?search=product%20name +GET /products?after=2017-03-22&date_column=date +GET /products?before=2017-03-22&date_column=date +GET /products?exclude=10,44,33 +GET /products?include=10,44,33 +GET /products?offset=10 +GET /products?order=asc&orderby=price +GET /products?parent=10 +GET /products?parent_exclude=10 +GET /products?type=simple +GET /products?sku=sku-1,sku-2 +GET /products?featured=true +GET /products?category=t-shirts +GET /products?tag=special-items +GET /products?attributes[0][attribute]=pa_color&attributes[0][slug]=red +GET /products?on_sale=true +GET /products?min_price=50 +GET /products?max_price=100 +GET /products?stock_status=outofstock +GET /products?catalog_visibility=search +GET /products?rating=4,5 +GET /products?return_price_range=true +GET /products?return_attribute_counts=pa_size,pa_color +GET /products?return_rating_counts=true +``` + +| Attribute | Type | Required | Description | +| :------------------- | :------ | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `search` | integer | no | Limit results to those matching a string. | +| `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` | +| `exclude` | array | no | Ensure result set excludes specific IDs. | +| `include` | array | no | Limit result set to specific ids. | +| `offset` | integer | no | Offset the result set by a specific number of items. | +| `order` | string | no | Order sort attribute ascending or descending. Allowed values: `asc`, `desc` | +| `orderby` | string | no | Sort collection by object attribute. Allowed values: `date`, `modified`, `id`, `include`, `title`, `slug`, `price`, `popularity`, `rating`, `menu_order`, `comment_count` | +| `parent` | array | no | Limit result set to those of particular parent IDs. | +| `parent_exclude` | array | no | Limit result set to all items except those of a particular parent ID. | +| `type` | string | no | Limit result set to products assigned a specific type. | +| `sku` | string | no | Limit result set to products with specific SKU(s). Use commas to separate. | +| `featured` | boolean | no | Limit result set to featured products. | +| `category` | string | no | Limit result set to products assigned a specific category ID. | +| `category_operator` | string | no | Operator to compare product category terms. Allowed values: `in`, `not_in`, `and` | +| `tag` | string | no | Limit result set to products assigned a specific tag ID. | +| `tag_operator` | string | no | Operator to compare product tags. Allowed values: `in`, `not_in`, `and` | +| `attributes` | array | no | Limit result set to specific attribute terms. Expects an array of objects containing `attribute` (taxonomy), `term_id` or `slug`, and optional `operator` for comparison. | +| `on_sale` | boolean | no | Limit result set to products on sale. | +| `min_price` | string | no | Limit result set to products based on a minimum price. | +| `max_price` | string | no | Limit result set to products based on a maximum price. | +| `stock_status` | string | no | Limit result set to products with specified stock status. | +| `catalog_visibility` | string | no | Determines if hidden or visible catalog products are shown. Allowed values: `any`, `visible`, `catalog`, `search`, `hidden` | +| `rating` | boolean | no | Limit result set to products with a certain average rating. | + +```http +curl "https://example-store.com/wp-json/wc/store/products" +``` + +Example response: + +```json +[ + { + "id": 95, + "name": "WordPress Pennant", + "variation": "", + "permalink": "http://local.wordpress.test/product/wordpress-pennant/", + "sku": "wp-pennant", + "description": "

This is an external product.

\n", + "price": "0", + "price_html": "£0.00", + "average_rating": "3.60", + "review_count": 5, + "images": [ + { + "id": 60, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/pennant-1.jpg", + "name": "pennant-1.jpg", + "alt": "" + } + ] + } +] +``` + +### Single product + +Get a single product. + +```http +GET /products/:id +``` + +| Attribute | Type | Required | Description | +| :-------- | :------ | :------: | :--------------------------------- | +| `id` | integer | Yes | The ID of the product to retrieve. | + +```http +curl "https://example-store.com/wp-json/wc/store/products/95" +``` + +Example response: + +```json +{ + "id": 95, + "name": "WordPress Pennant", + "variation": "", + "permalink": "http://local.wordpress.test/product/wordpress-pennant/", + "sku": "wp-pennant", + "description": "

This is an external product.

\n", + "price": "0", + "price_html": "£0.00", + "average_rating": "3.60", + "review_count": 5, + "images": [ + { + "id": 60, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/pennant-1.jpg", + "name": "pennant-1.jpg", + "alt": "" + } + ] +} +``` + +## Cart API + +```http +GET /cart +``` + +There are no parameters required for this endpoint. + +```http +curl "https://example-store.com/wp-json/wc/store/cart" +``` + +Example response: + +```json +{ + "currency": "GBP", + "item_count": 2, + "items": [ + { + "key": "11a7ec12ea1071fdecd917d6da5c87ae", + "id": 88, + "quantity": 2, + "name": "V-Neck T-Shirt", + "sku": "woo-vneck-tee-blue", + "permalink": "http://local.wordpress.test/product/v-neck-t-shirt/?attribute_pa_color=blue", + "images": [ + { + "id": 41, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/vnech-tee-blue-1.jpg", + "name": "vnech-tee-blue-1.jpg", + "alt": "" + } + ], + "price": "15.00", + "line_price": "30.00", + "variation": { + "Color": "Blue", + "Size": "Small" + } + } + ], + "needs_shipping": true, + "total_price": "30.00", + "total_weight": 0 +} +``` + +## Cart items API + +### List cart items + +```http +GET /cart/items +``` + +There are no parameters required for this endpoint. + +```http +curl "https://example-store.com/wp-json/wc/store/cart/items" +``` + +Example response: + +```json +[ + { + "key": "11a7ec12ea1071fdecd917d6da5c87ae", + "id": 88, + "quantity": 2, + "name": "V-Neck T-Shirt", + "sku": "woo-vneck-tee-blue", + "permalink": "http://local.wordpress.test/product/v-neck-t-shirt/?attribute_pa_color=blue", + "images": [ + { + "id": 41, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/vnech-tee-blue-1.jpg", + "name": "vnech-tee-blue-1.jpg", + "alt": "" + } + ], + "price": "15.00", + "line_price": "30.00", + "variation": { + "Color": "Blue", + "Size": "Small" + } + } +] +``` + +### Single cart item + +Get a single cart item. + +```http +GET /cart/items/:key +``` + +| Attribute | Type | Required | Description | +| :-------- | :----- | :------: | :------------------------------------ | +| `key` | string | Yes | The key of the cart item to retrieve. | + +```http +curl "https://example-store.com/wp-json/wc/store/cart/items/11a7ec12ea1071fdecd917d6da5c87ae" +``` + +Example response: + +```json +{ + "key": "11a7ec12ea1071fdecd917d6da5c87ae", + "id": 88, + "quantity": 2, + "name": "V-Neck T-Shirt", + "sku": "woo-vneck-tee-blue", + "permalink": "http://local.wordpress.test/product/v-neck-t-shirt/?attribute_pa_color=blue", + "images": [ + { + "id": 41, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/vnech-tee-blue-1.jpg", + "name": "vnech-tee-blue-1.jpg", + "alt": "" + } + ], + "price": "15.00", + "line_price": "30.00", + "variation": { + "Color": "Blue", + "Size": "Small" + } +} +``` + +### New cart item + +Add an item to the cart. + +```http +POST /cart/items/ +``` + +| Attribute | Type | Required | Description | +| :---------- | :------ | :------: | :--------------------------------------------------------------------------------------------------- | +| `id` | integer | Yes | The cart item product or variation ID. | +| `quantity` | integer | Yes | Quantity of this item in the cart. | +| `variation` | array | Yes | Chosen attributes (for variations) containing an array of objects with keys `attribute` and `value`. | + +```http +curl --request POST https://example-store.com/wp-json/wc/store/cart/items?id=100&quantity=1 +``` + +Example response: + +```json +{ + "key": "3ef815416f775098fe977004015c6193", + "id": 100, + "quantity": 1, + "name": "Single", + "sku": "woo-single", + "permalink": "http://local.wordpress.test/product/single/", + "images": [ + { + "id": 56, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/single-1.jpg", + "name": "single-1.jpg", + "alt": "" + } + ], + "price": "2.00", + "line_price": "2.00", + "variation": [] +} +``` + +### Edit single cart item + +Edit an item in the cart. + +```http +PUT /cart/items/:key +``` + +| Attribute | Type | Required | Description | +| :--------- | :------ | :------: | :--------------------------------- | +| `key` | string | Yes | The key of the cart item to edit. | +| `quantity` | integer | Yes | Quantity of this item in the cart. | + +```http +curl --request PUT https://example-store.com/wp-json/wc/store/cart/items/3ef815416f775098fe977004015c6193&quantity=10 +``` + +Example response: + +```json +{ + "key": "3ef815416f775098fe977004015c6193", + "id": 100, + "quantity": 10, + "name": "Single", + "sku": "woo-single", + "permalink": "http://local.wordpress.test/product/single/", + "images": [ + { + "id": 56, + "src": "http://local.wordpress.test/wp-content/uploads/2019/07/single-1.jpg", + "name": "single-1.jpg", + "alt": "" + } + ], + "price": "2.00", + "line_price": "20.00", + "variation": [] +} +``` + +### Delete single cart item + +Delete/remove an item from the cart. + +```http +DELETE /cart/items/:key +``` + +| Attribute | Type | Required | Description | +| :-------- | :----- | :------: | :-------------------------------- | +| `key` | string | Yes | The key of the cart item to edit. | + +```http +curl --request DELETE https://example-store.com/wp-json/wc/store/cart/items/3ef815416f775098fe977004015c6193 +``` + +### Delete all cart items + +Delete/remove all items from the cart. + +```http +DELETE /cart/items/ +``` + +There are no parameters required for this endpoint. + +```http +curl --request DELETE https://example-store.com/wp-json/wc/store/cart/items +``` + +Example response: + +```json +[] +``` diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/AbstractSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/AbstractSchema.php index 028d92e661f..1c4d807daac 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/AbstractSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/AbstractSchema.php @@ -13,6 +13,8 @@ defined( 'ABSPATH' ) || exit; /** * AbstractBlock class. + * + * @since $VID:$ */ abstract class AbstractSchema { /** @@ -42,4 +44,23 @@ abstract class AbstractSchema { * @return array */ abstract protected function get_properties(); + + /** + * Force all schema properties to be readonly. + * + * @param array $properties Schema. + * @return array Updated schema. + */ + protected function force_schema_readonly( $properties ) { + return array_map( + function( $property ) { + $property['readonly'] = true; + if ( isset( $property['items']['properties'] ) ) { + $property['items']['properties'] = $this->force_schema_readonly( $property['items']['properties'] ); + } + return $property; + }, + $properties + ); + } } diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php index 3916596089b..6894201f025 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartItemSchema.php @@ -15,6 +15,8 @@ use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductImages; /** * AbstractBlock class. + * + * @since $VID:$ */ class CartItemSchema extends AbstractSchema { /** diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php index a0509f24ca8..adb25aa6fa7 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/CartSchema.php @@ -11,6 +11,8 @@ defined( 'ABSPATH' ) || exit; /** * CartSchema class. + * + * @since $VID:$ */ class CartSchema extends AbstractSchema { /** @@ -48,7 +50,7 @@ class CartSchema extends AbstractSchema { 'readonly' => true, 'items' => array( 'type' => 'object', - 'properties' => ( new CartItemSchema() )->get_properties(), + 'properties' => $this->force_schema_readonly( ( new CartItemSchema() )->get_properties() ), ), ), 'needs_shipping' => array( diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php new file mode 100644 index 00000000000..7af7eeaeb02 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Schemas/ProductSchema.php @@ -0,0 +1,146 @@ + array( + 'description' => __( 'Unique identifier for the resource.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Product name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'variation' => array( + 'description' => __( 'Product variation attributes, if applicable.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'permalink' => array( + 'description' => __( 'Product URL.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'Short description or excerpt from description.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'sku' => array( + 'description' => __( 'Unique identifier.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'price' => array( + 'description' => __( 'Current product price.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'price_html' => array( + 'description' => __( 'Price formatted in HTML.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'average_rating' => array( + 'description' => __( 'Reviews average rating.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'review_count' => array( + 'description' => __( 'Amount of reviews that the product has.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'images' => array( + 'description' => __( 'List of images.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Image ID.', 'woo-gutenberg-products-block' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + ), + 'src' => array( + 'description' => __( 'Image URL.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit' ), + ), + 'name' => array( + 'description' => __( 'Image name.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'alt' => array( + 'description' => __( 'Image alternative text.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + ), + ), + ), + ]; + } + + /** + * Convert a WooCommerce product into an object suitable for the response. + * + * @param array $product Product object. + * @return array + */ + public function get_item_response( $product ) { + return [ + 'id' => $product->get_id(), + 'name' => $product->get_title(), + 'variation' => $product->is_type( 'variation' ) ? wc_get_formatted_variation( $product, true, true, false ) : '', + 'permalink' => $product->get_permalink(), + 'sku' => $product->get_sku(), + 'description' => apply_filters( 'woocommerce_short_description', $product->get_short_description() ? $product->get_short_description() : wc_trim_string( $product->get_description(), 400 ) ), + 'price' => $product->get_price(), + 'price_html' => $product->get_price_html(), + 'average_rating' => $product->get_average_rating(), + 'review_count' => $product->get_review_count(), + 'images' => ( new ProductImages() )->images_to_array( $product ), + ]; + } +} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php index 3b1309adaa0..7b07a496b37 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php @@ -16,6 +16,8 @@ use \WC_REST_Exception as RestException; /** * Woo Cart Controller class. + * + * @since $VID:$ */ class CartController { diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/Pagination.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/Pagination.php new file mode 100644 index 00000000000..c564199343e --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/Pagination.php @@ -0,0 +1,81 @@ +header( 'X-WP-Total', $total_items ); + $response->header( 'X-WP-TotalPages', $total_pages ); + + $current_page = $this->get_current_page( $request ); + $link_base = $this->get_link_base( $request ); + + if ( $current_page > 1 ) { + $previous_page = $current_page - 1; + if ( $previous_page > $total_pages ) { + $previous_page = $total_pages; + } + $this->add_page_link( $response, 'prev', $previous_page, $link_base ); + } + + if ( $total_pages > $current_page ) { + $this->add_page_link( $response, 'next', ( $current_page + 1 ), $link_base ); + } + + return $response; + } + + /** + * Get current page. + * + * @param \WP_REST_Request $request The request object. + * @return int Get the page from the request object. + */ + protected function get_current_page( $request ) { + return (int) $request->get_param( 'page' ); + } + + /** + * Get base for links from the request object. + * + * @param \WP_REST_Request $request The request object. + * @return string + */ + protected function get_link_base( $request ) { + return add_query_arg( $request->get_query_params(), rest_url( $request->get_route() ) ); + } + + /** + * Add a page link. + * + * @param \WP_REST_Response $response Reference to the response object. + * @param string $name Page link name. e.g. prev. + * @param int $page Page number. + * @param string $link_base Base URL. + */ + protected function add_page_link( &$response, $name, $page, $link_base ) { + $response->link_header( $name, add_query_arg( 'page', $page, $link_base ) ); + } +} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php new file mode 100644 index 00000000000..c4b286b97e7 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductFiltering.php @@ -0,0 +1,142 @@ +prepare_objects_query( $request ); + $query_args['no_found_rows'] = true; + $query_args['posts_per_page'] = -1; + $query = new \WP_Query(); + $result = $query->query( $query_args ); + $product_query_sql = $query->request; + + remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); + remove_filter( 'posts_pre_query', '__return_empty_array' ); + + $price_filter_sql = " + SELECT min( min_price ) as min_price, MAX( max_price ) as max_price + FROM {$wpdb->wc_product_meta_lookup} + WHERE product_id IN ( {$product_query_sql} ) + "; + + return $wpdb->get_row( $price_filter_sql ); // phpcs:ignore + } + + /** + * Get attribute counts for the current products. + * + * @param \WP_REST_Request $request The request object. + * @param array $attribute_names Attributes to count. + * @return array termId=>count pairs. + */ + public function get_attribute_counts( $request, $attribute_names = [] ) { + global $wpdb; + + // Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products. + $product_query = new ProductQuery(); + + add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 ); + add_filter( 'posts_pre_query', '__return_empty_array' ); + + $query_args = $product_query->prepare_objects_query( $request ); + $query_args['no_found_rows'] = true; + $query_args['posts_per_page'] = -1; + $query = new \WP_Query(); + $result = $query->query( $query_args ); + $product_query_sql = $query->request; + + remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); + remove_filter( 'posts_pre_query', '__return_empty_array' ); + + $attributes_to_count = array_map( 'wc_sanitize_taxonomy_name', $attribute_names ); + $attributes_to_count_sql = 'AND term_taxonomy.taxonomy IN ("' . implode( '","', $attributes_to_count ) . '")'; + $attribute_count_sql = " + SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id + FROM {$wpdb->posts} AS posts + INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id + INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id ) + INNER JOIN {$wpdb->terms} AS terms USING( term_id ) + WHERE posts.ID IN ( {$product_query_sql} ) + {$attributes_to_count_sql} + GROUP BY terms.term_id + "; + + $results = $wpdb->get_results( $attribute_count_sql ); // phpcs:ignore + + return array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) ); + } + + /** + * Get rating counts for the current products. + * + * @param \WP_REST_Request $request The request object. + * @return array rating=>count pairs. + */ + public function get_rating_counts( $request ) { + global $wpdb; + + // Regenerate the products query without rating request params. + unset( $request['rating'] ); + + // Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products. + $product_query = new ProductQuery(); + + add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 ); + add_filter( 'posts_pre_query', '__return_empty_array' ); + + $query_args = $product_query->prepare_objects_query( $request ); + $query_args['no_found_rows'] = true; + $query_args['posts_per_page'] = -1; + $query = new \WP_Query(); + $result = $query->query( $query_args ); + $product_query_sql = $query->request; + + remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 ); + remove_filter( 'posts_pre_query', '__return_empty_array' ); + + $rating_count_sql = " + SELECT COUNT( DISTINCT product_id ) as product_count, ROUND( average_rating, 0 ) as rounded_average_rating + FROM {$wpdb->wc_product_meta_lookup} + WHERE product_id IN ( {$product_query_sql} ) + AND average_rating > 0 + GROUP BY rounded_average_rating + ORDER BY rounded_average_rating ASC + "; + + $results = $wpdb->get_results( $rating_count_sql ); // phpcs:ignore + + return array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) ); + } +} diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductQuery.php new file mode 100644 index 00000000000..f5555b1a116 --- /dev/null +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/ProductQuery.php @@ -0,0 +1,349 @@ + 'category', + 'product_tag' => 'tag', + ); + + // Set tax_query for each passed arg. + foreach ( $taxonomies as $taxonomy => $key ) { + if ( ! empty( $request[ $key ] ) ) { + $tax_query[] = array( + 'taxonomy' => $taxonomy, + 'field' => 'term_id', + 'terms' => $request[ $key ], + ); + } + } + + // Filter product type by slug. + if ( ! empty( $request['type'] ) ) { + $tax_query[] = array( + 'taxonomy' => 'product_type', + 'field' => 'slug', + 'terms' => $request['type'], + ); + } + + // Filter by attributes. + if ( ! empty( $request['attributes'] ) ) { + foreach ( $request['attributes'] as $attribute ) { + if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) { + continue; + } + if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) { + $tax_query[] = array( + 'taxonomy' => $attribute['attribute'], + 'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug', + 'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'], + 'operator' => isset( $attribute['operator'] ) ? $attribute['operator'] : 'IN', + ); + } + } + } + + // Build tax_query if taxonomies are set. + if ( ! empty( $tax_query ) ) { + if ( ! empty( $args['tax_query'] ) ) { + $args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore + } else { + $args['tax_query'] = $tax_query; // phpcs:ignore + } + } + + // Filter featured. + if ( is_bool( $request['featured'] ) ) { + $args['tax_query'][] = array( + 'taxonomy' => 'product_visibility', + 'field' => 'name', + 'terms' => 'featured', + 'operator' => true === $request['featured'] ? 'IN' : 'NOT IN', + ); + } + + // Filter by on sale products. + if ( is_bool( $request['on_sale'] ) ) { + $on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in'; + $on_sale_ids = wc_get_product_ids_on_sale(); + + // Use 0 when there's no on sale products to avoid return all products. + $on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids; + + $args[ $on_sale_key ] += $on_sale_ids; + } + + $operator_mapping = array( + 'in' => 'IN', + 'not_in' => 'NOT IN', + 'and' => 'AND', + ); + + if ( isset( $args['tax_query'] ) ) { + $category_operator = $request->get_param( 'category_operator' ); + $tag_operator = $request->get_param( 'tag_operator' ); + $attribute_operator = $request->get_param( 'attribute_operator' ); + + foreach ( $args['tax_query'] as $i => $tax_query ) { + if ( $category_operator && 'product_cat' === $tax_query['taxonomy'] ) { + $operator = isset( $operator_mapping[ $category_operator ] ) ? $operator_mapping[ $category_operator ] : 'IN'; + + $args['tax_query'][ $i ]['operator'] = $operator; + $args['tax_query'][ $i ]['include_children'] = 'AND' === $operator ? false : true; + } + if ( 'product_tag' === $tax_query['taxonomy'] ) { + $operator = isset( $operator_mapping[ $tag_operator ] ) ? $operator_mapping[ $tag_operator ] : 'IN'; + + $args['tax_query'][ $i ]['operator'] = $operator; + } + if ( in_array( $tax_query['taxonomy'], wc_get_attribute_taxonomy_names(), true ) ) { + $operator = isset( $operator_mapping[ $attribute_operator ] ) ? $operator_mapping[ $attribute_operator ] : 'IN'; + + $args['tax_query'][ $i ]['operator'] = $operator; + } + } + } + + $catalog_visibility = $request->get_param( 'catalog_visibility' ); + $rating = $request->get_param( 'rating' ); + $visibility_options = wc_get_product_visibility_options(); + + if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) { + $exclude_from_catalog = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog'; + $exclude_from_search = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search'; + + $args['tax_query'][] = array( + 'taxonomy' => 'product_visibility', + 'field' => 'name', + 'terms' => array( $exclude_from_catalog, $exclude_from_search ), + 'operator' => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN', + 'rating_filter' => true, + ); + } + + if ( $rating ) { + $rating_terms = []; + foreach ( $rating as $value ) { + $rating_terms[] = 'rated-' . $value; + } + $args['tax_query'][] = array( + 'taxonomy' => 'product_visibility', + 'field' => 'name', + 'terms' => $rating_terms, + ); + } + + // Force the post_type argument, since it's not a user input variable. + if ( ! empty( $request['sku'] ) ) { + $args['post_type'] = array( 'product', 'product_variation' ); + } else { + $args['post_type'] = 'product'; + } + + $orderby = $request->get_param( 'orderby' ); + $order = $request->get_param( 'order' ); + + $ordering_args = WC()->query->get_catalog_ordering_args( $orderby, $order ); + $args['orderby'] = $ordering_args['orderby']; + $args['order'] = $ordering_args['order']; + + if ( 'include' === $orderby ) { + $args['orderby'] = 'post__in'; + } elseif ( 'id' === $orderby ) { + $args['orderby'] = 'ID'; // ID must be capitalized. + } elseif ( 'slug' === $orderby ) { + $args['orderby'] = 'name'; + } + + if ( $ordering_args['meta_key'] ) { + $args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore + } + + return $args; + } + + /** + * Get objects. + * + * @param \WP_REST_Request $request Request data. + * @return array + */ + public function get_objects( $request ) { + $query_args = $this->prepare_objects_query( $request ); + + add_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10, 2 ); + + $query = new \WP_Query(); + $result = $query->query( $query_args ); + $total_posts = $query->found_posts; + + // Out-of-bounds, run the query again without LIMIT for total count. + if ( $total_posts < 1 ) { + unset( $query_args['paged'] ); + $count_query = new \WP_Query(); + $count_query->query( $query_args ); + $total_posts = $count_query->found_posts; + } + + remove_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10 ); + + return array( + 'objects' => array_map( 'wc_get_product', $result ), + 'total' => (int) $total_posts, + 'pages' => (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ), + ); + } + + /** + * Add in conditional search filters for products. + * + * @param array $args Query args. + * @param \WC_Query $wp_query WC_Query object. + * @return array + */ + public function add_query_clauses( $args, $wp_query ) { + global $wpdb; + + if ( $wp_query->get( 'search' ) ) { + $search = "'%" . $wpdb->esc_like( $wp_query->get( 'search' ) ) . "%'"; + $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); + $args['where'] .= " AND ({$wpdb->posts}.post_title LIKE {$search}"; + $args['where'] .= wc_product_sku_enabled() ? ' OR wc_product_meta_lookup.sku LIKE ' . $search . ')' : ')'; + } + + if ( $wp_query->get( 'sku' ) ) { + $skus = explode( ',', $wp_query->get( 'sku' ) ); + // Include the current string as a SKU too. + if ( 1 < count( $skus ) ) { + $skus[] = $wp_query->get( 'sku' ); + } + $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); + $args['where'] .= ' AND wc_product_meta_lookup.sku IN ("' . implode( '","', array_map( 'esc_sql', $skus ) ) . '")'; + } + + if ( $wp_query->get( 'min_price' ) ) { + $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); + $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price >= %f ', floatval( $wp_query->get( 'min_price' ) ) ); + } + + if ( $wp_query->get( 'max_price' ) ) { + $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); + $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price <= %f ', floatval( $wp_query->get( 'max_price' ) ) ); + } + + if ( $wp_query->get( 'stock_status' ) ) { + $args['join'] = $this->append_product_sorting_table_join( $args['join'] ); + $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.stock_status = %s ', $wp_query->get( 'stock_status' ) ); + } + + return $args; + } + + /** + * Add meta query. + * + * @param array $args Query args. + * @param array $meta_query Meta query. + * @return array + */ + protected function add_meta_query( $args, $meta_query ) { + if ( empty( $args['meta_query'] ) ) { + $args['meta_query'] = []; // phpcs:ignore + } + + $args['meta_query'][] = $meta_query; + + return $args['meta_query']; + } + + /** + * Join wc_product_meta_lookup to posts if not already joined. + * + * @param string $sql SQL join. + * @return string + */ + protected function append_product_sorting_table_join( $sql ) { + global $wpdb; + + if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) { + $sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; + } + return $sql; + } +} diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php index 62ad51d8265..e384ba1ee36 100644 --- a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Cart.php @@ -17,8 +17,6 @@ use \WC_Helper_Product as ProductHelper; class Cart extends TestCase { /** * Setup test products data. Called before every test. - * - * @since 1.2.0 */ public function setUp() { parent::setUp(); @@ -49,8 +47,6 @@ class Cart extends TestCase { /** * Test route registration. - * - * @since 3.6.0 */ public function test_register_routes() { $routes = $this->server->get_routes(); diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php index a3f8c908645..cd6a7ce88f5 100644 --- a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/CartItems.php @@ -17,8 +17,6 @@ use \WC_Helper_Product as ProductHelper; class CartItems extends TestCase { /** * Setup test products data. Called before every test. - * - * @since 1.2.0 */ public function setUp() { parent::setUp(); @@ -49,8 +47,6 @@ class CartItems extends TestCase { /** * Test route registration. - * - * @since 3.6.0 */ public function test_register_routes() { $routes = $this->server->get_routes(); @@ -86,7 +82,7 @@ class CartItems extends TestCase { $this->assertEquals( '10.00', $data['price'] ); $this->assertEquals( '20.00', $data['line_price'] ); - $request = new WP_REST_Request( 'DELETE', '/wc/store/cart/items/XXX815416f775098fe977004015c6193' ); + $request = new WP_REST_Request( 'DELETE', '/wc/store/cart/items/XXX815416f775098fe977004015c6193' ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); @@ -109,14 +105,14 @@ class CartItems extends TestCase { $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 201, $response->get_status() ); $this->assertEquals( $this->products[0]->get_id(), $data['id'] ); $this->assertEquals( 10, $data['quantity'] ); $response = $this->server->dispatch( $request ); $data = $response->get_data(); - $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 201, $response->get_status() ); $this->assertEquals( $this->products[0]->get_id(), $data['id'] ); $this->assertEquals( 20, $data['quantity'] ); } diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php new file mode 100644 index 00000000000..aa92461d1fe --- /dev/null +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/ProductCollectionData.php @@ -0,0 +1,172 @@ +products = []; + $this->products[0] = ProductHelper::create_simple_product( false ); + $this->products[0]->set_regular_price( 10 ); + $this->products[0]->save(); + + $this->products[1] = ProductHelper::create_simple_product( false ); + $this->products[1]->set_regular_price( 100 ); + $this->products[1]->save(); + + wp_insert_comment( [ + 'comment_post_ID' => $this->products[0]->get_id(), + 'comment_author' => 'admin', + 'comment_author_email' => 'woo@woo.local', + 'comment_author_url' => '', + 'comment_content' => 'Good product.', + 'comment_approved' => 1, + 'comment_type' => 'review', + 'comment_meta' => [ + 'rating' => 5, + ] + ] ); + + wp_insert_comment( [ + 'comment_post_ID' => $this->products[1]->get_id(), + 'comment_author' => 'admin', + 'comment_author_email' => 'woo@woo.local', + 'comment_author_url' => '', + 'comment_content' => 'Another very good product.', + 'comment_approved' => 1, + 'comment_type' => 'review', + 'comment_meta' => [ + 'rating' => 4, + ] + ] ); + + \WC_Comments::clear_transients( $this->products[0]->get_id() ); + \WC_Comments::clear_transients( $this->products[1]->get_id() ); + } + + /** + * Test route registration. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wc/store/products/collection-data', $routes ); + } + + /** + * Test getting items. + */ + public function test_get_items() { + $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/store/products/collection-data' ) ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( null, $data['min_price'] ); + $this->assertEquals( null, $data['max_price'] ); + $this->assertEquals( null, $data['attribute_counts'] ); + $this->assertEquals( null, $data['rating_counts'] ); + } + + /** + * Test calculation method. + */ + public function test_calculate_price_range() { + $request = new WP_REST_Request( 'GET', '/wc/store/products/collection-data' ); + $request->set_param( 'calculate_price_range', true ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( '10.00', $data['min_price'] ); + $this->assertEquals( '100.00', $data['max_price'] ); + $this->assertEquals( null, $data['attribute_counts'] ); + $this->assertEquals( null, $data['rating_counts'] ); + } + + /** + * Test calculation method. + */ + public function test_calculate_attribute_counts() { + ProductHelper::create_variation_product(); + + $request = new WP_REST_Request( 'GET', '/wc/store/products/collection-data' ); + $request->set_param( 'calculate_attribute_counts', 'pa_size' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( null, $data['min_price'] ); + $this->assertEquals( null, $data['max_price'] ); + $this->assertEquals( null, $data['rating_counts'] ); + + $this->assertArrayHasKey( 'term', $data['attribute_counts'][0] ); + $this->assertArrayHasKey( 'count', $data['attribute_counts'][0] ); + } + + /** + * Test calculation method. + */ + public function test_calculate_rating_counts() { + $request = new WP_REST_Request( 'GET', '/wc/store/products/collection-data' ); + $request->set_param( 'calculate_rating_counts', true ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( null, $data['min_price'] ); + $this->assertEquals( null, $data['max_price'] ); + $this->assertEquals( null, $data['attribute_counts'] ); + $this->assertEquals( [ + [ + 'rating' => 4, + 'count' => 1, + ], + [ + 'rating' => 5, + 'count' => 1, + ] + ], $data['rating_counts'] ); + } + + /** + * Test schema retrieval. + */ + public function test_get_item_schema() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\ProductCollectionData(); + $schema = $controller->get_item_schema(); + + $this->assertArrayHasKey( 'min_price', $schema['properties'] ); + $this->assertArrayHasKey( 'max_price', $schema['properties'] ); + $this->assertArrayHasKey( 'attribute_counts', $schema['properties'] ); + $this->assertArrayHasKey( 'rating_counts', $schema['properties'] ); + } + + /** + * Test collection params getter. + */ + public function test_get_collection_params() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\ProductCollectionData(); + $params = $controller->get_collection_params(); + + $this->assertArrayHasKey( 'calculate_price_range', $params ); + $this->assertArrayHasKey( 'calculate_attribute_counts', $params ); + $this->assertArrayHasKey( 'calculate_rating_counts', $params ); + } +} diff --git a/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Products.php b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Products.php new file mode 100644 index 00000000000..76931acbc48 --- /dev/null +++ b/plugins/woocommerce-blocks/tests/php/RestApi/StoreApi/Controllers/Products.php @@ -0,0 +1,158 @@ +products = []; + $this->products[0] = ProductHelper::create_simple_product( false ); + $this->products[0]->save(); + $this->products[1] = ProductHelper::create_simple_product( false ); + $this->products[1]->save(); + } + + /** + * Test route registration. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wc/store/products', $routes ); + $this->assertArrayHasKey( '/wc/store/products/(?P[\d]+)', $routes ); + } + + /** + * Test getting item. + */ + public function test_get_item() { + $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/store/products/' . $this->products[0]->get_id() ) ); + $data = $response->get_data(); + + $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_permalink(), $data['permalink'] ); + $this->assertEquals( $this->products[0]->get_sku(), $data['sku'] ); + $this->assertEquals( $this->products[0]->get_price(), $data['price'] ); + $this->assertEquals( $this->products[0]->get_price_html(), $data['price_html'] ); + $this->assertEquals( $this->products[0]->get_average_rating(), $data['average_rating'] ); + $this->assertEquals( $this->products[0]->get_review_count(), $data['review_count'] ); + } + + /** + * Test getting items. + */ + public function test_get_items() { + $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/store/products' ) ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 2, count( $data ) ); + $this->assertArrayHasKey( 'id', $data[0] ); + $this->assertArrayHasKey( 'name', $data[0] ); + $this->assertArrayHasKey( 'variation', $data[0] ); + $this->assertArrayHasKey( 'permalink', $data[0] ); + $this->assertArrayHasKey( 'description', $data[0] ); + $this->assertArrayHasKey( 'sku', $data[0] ); + $this->assertArrayHasKey( 'price', $data[0] ); + $this->assertArrayHasKey( 'price_html', $data[0] ); + $this->assertArrayHasKey( 'average_rating', $data[0] ); + $this->assertArrayHasKey( 'review_count', $data[0] ); + $this->assertArrayHasKey( 'images', $data[0] ); + } + + /** + * Test schema retrieval. + */ + public function test_get_item_schema() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\Products(); + $schema = $controller->get_item_schema(); + + $this->assertArrayHasKey( 'id', $schema['properties'] ); + $this->assertArrayHasKey( 'name', $schema['properties'] ); + $this->assertArrayHasKey( 'variation', $schema['properties'] ); + $this->assertArrayHasKey( 'permalink', $schema['properties'] ); + $this->assertArrayHasKey( 'description', $schema['properties'] ); + $this->assertArrayHasKey( 'sku', $schema['properties'] ); + $this->assertArrayHasKey( 'price', $schema['properties'] ); + $this->assertArrayHasKey( 'price_html', $schema['properties'] ); + $this->assertArrayHasKey( 'average_rating', $schema['properties'] ); + $this->assertArrayHasKey( 'review_count', $schema['properties'] ); + $this->assertArrayHasKey( 'images', $schema['properties'] ); + } + + /** + * Test conversion of prdouct to rest response. + */ + public function test_prepare_item_for_response() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\Products(); + $response = $controller->prepare_item_for_response( $this->products[0], [] ); + + $this->assertArrayHasKey( 'id', $response->get_data() ); + $this->assertArrayHasKey( 'name', $response->get_data() ); + $this->assertArrayHasKey( 'variation', $response->get_data() ); + $this->assertArrayHasKey( 'permalink', $response->get_data() ); + $this->assertArrayHasKey( 'description', $response->get_data() ); + $this->assertArrayHasKey( 'sku', $response->get_data() ); + $this->assertArrayHasKey( 'price', $response->get_data() ); + $this->assertArrayHasKey( 'price_html', $response->get_data() ); + $this->assertArrayHasKey( 'average_rating', $response->get_data() ); + $this->assertArrayHasKey( 'review_count', $response->get_data() ); + $this->assertArrayHasKey( 'images', $response->get_data() ); + } + + /** + * Test collection params getter. + */ + public function test_get_collection_params() { + $controller = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Controllers\Products(); + $params = $controller->get_collection_params(); + + $this->assertArrayHasKey( 'page', $params ); + $this->assertArrayHasKey( 'per_page', $params ); + $this->assertArrayHasKey( 'search', $params ); + $this->assertArrayHasKey( 'after', $params ); + $this->assertArrayHasKey( 'before', $params ); + $this->assertArrayHasKey( 'date_column', $params ); + $this->assertArrayHasKey( 'exclude', $params ); + $this->assertArrayHasKey( 'include', $params ); + $this->assertArrayHasKey( 'offset', $params ); + $this->assertArrayHasKey( 'order', $params ); + $this->assertArrayHasKey( 'orderby', $params ); + $this->assertArrayHasKey( 'parent', $params ); + $this->assertArrayHasKey( 'parent_exclude', $params ); + $this->assertArrayHasKey( 'type', $params ); + $this->assertArrayHasKey( 'sku', $params ); + $this->assertArrayHasKey( 'featured', $params ); + $this->assertArrayHasKey( 'category', $params ); + $this->assertArrayHasKey( 'tag', $params ); + $this->assertArrayHasKey( 'on_sale', $params ); + $this->assertArrayHasKey( 'min_price', $params ); + $this->assertArrayHasKey( 'max_price', $params ); + $this->assertArrayHasKey( 'stock_status', $params ); + $this->assertArrayHasKey( 'category_operator', $params ); + $this->assertArrayHasKey( 'tag_operator', $params ); + $this->assertArrayHasKey( 'attribute_operator', $params ); + $this->assertArrayHasKey( 'attributes', $params ); + $this->assertArrayHasKey( 'catalog_visibility', $params ); + $this->assertArrayHasKey( 'rating', $params ); + } +}