Commit 07c3c7ab9d for woocommerce
commit 07c3c7ab9d61612c14ea233c96d65899af3accbe
Author: Abdalsalaam Halawa <abdalsalaamnafez@gmail.com>
Date: Fri Feb 20 14:15:03 2026 +0200
Add upsells, cross-sells, and related products to Store API Products endpoint (#62603)
* Add related_ids, upsell_ids, and cross_sell_ids
* add tests
* Add changefile(s) from automation for the following project(s): woocommerce
* Update @SInCE version placeholders
* fix returned array mapping
* remove unnecessary 'delete_transient'
* add the new fields to product endpoint response doc
* revert adding related_ids, upsell_ids, and cross_sell_ids to the response
* Add trait ProductLinksTrait
Shared functionality for preparing product links including embeddable links for upsells, cross-sells, and related products
* Use ProductLinksTrait to add _links to the items
* Use ProductLinksTrait to add _links to the item object
* Add 'embed' context
* Add changefile(s) from automation for the following project(s): woocommerce
* force absint for get_upsell_ids and get_cross_sell_ids
* draft filter by related products query
* Use related param for related link
* Limit to 5 related products
* Add Product Links and Embedding to the documentation
* fix markdown
* Add Products endpoint _links tests
* set per_page to 10
* cape per_page limit at 100 to prevent abuse
* make related support only one ID
* update param
* related products doc
* update tests for related
* update related per_page test
* update related link example
* test Related product should be returned
* remove redundant array( $product_id ) exclusion
* Adding validate_callback to ensures consistent validation behavior
* lint fixes
* Guard is not needed for autoloaded classes
* Correctly handle errors from rest_ensure_response
* Use prepare_item_for_response in products route
* Update phpstan baseline for CI
* Added 'embed' to context arrays
* schema natively includes 'embed'
* baseline update
---------
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Mike Jolley <mike.jolley@me.com>
diff --git a/docs/apis/store-api/resources-endpoints/products.md b/docs/apis/store-api/resources-endpoints/products.md
index 7e13789504..98ab9308e6 100644
--- a/docs/apis/store-api/resources-endpoints/products.md
+++ b/docs/apis/store-api/resources-endpoints/products.md
@@ -30,6 +30,7 @@ GET /products?max_price=10000
GET /products?stock_status=['outofstock']
GET /products?catalog_visibility=search
GET /products?rating=4,5
+GET /products?related=34
GET /products?return_price_range=true
GET /products?return_attribute_counts=pa_size,pa_color
GET /products?return_rating_counts=true
@@ -68,6 +69,7 @@ GET /products?return_rating_counts=true
| `attribute_relation` | string | no | The logical relationship between attributes when filtering across multiple at once. |
| `catalog_visibility` | string | no | Determines if hidden or visible catalog products are shown. Allowed values: `any`, `visible`, `catalog`, `search`, `hidden` |
| `rating` | array | no | Limit result set to products with a certain average rating. Allowed values: `1`, `2`, `3`, `4`, `5`. |
+| `related` | integer | no | Limit result set to products related to a specific product ID. |
```sh
curl "https://example-store.com/wp-json/wc/store/v1/products"
@@ -260,6 +262,65 @@ curl "https://example-store.com/wp-json/wc/store/v1/products/wordpress-pennant"
}
```
+## Product Links and Embedding
+
+Product responses include `_links` that provide URLs to related resources. When products have upsells, cross-sells, or related products configured, embeddable links are included that can be used with WordPress's `_embed` feature.
+
+### Available Links
+
+| Link | Description | Embeddable |
+| :----------- | :-------------------------------------------------- | :--------: |
+| `self` | Link to the current product | No |
+| `collection` | Link to the products collection | No |
+| `up` | Link to parent product (for variations) | No |
+| `upsells` | Link to fetch upsell products (if configured) | Yes |
+| `cross_sells`| Link to fetch cross-sell products (if configured) | Yes |
+| `related` | Link to fetch related products | Yes |
+
+### Example Response with Links
+
+```json
+{
+ "id": 34,
+ "name": "WordPress Pennant",
+ "_links": {
+ "self": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products/34"}],
+ "collection": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products"}],
+ "upsells": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products?include=10,20", "embeddable": true}],
+ "cross_sells": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products?include=30", "embeddable": true}],
+ "related": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products?related=34&per_page=10", "embeddable": true}]
+ }
+}
+```
+
+### Using the `_embed` Parameter
+
+Add `?_embed` to any product request to automatically fetch and include the linked resources in an `_embedded` object:
+
+```sh
+curl "https://local.wordpress.test/wp-json/wc/store/v1/products/34?_embed"
+```
+
+**Example response with embedding:**
+
+```json
+{
+ "id": 34,
+ "name": "WordPress Pennant",
+ "_links": {
+ "self": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products/34"}],
+ "collection": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products"}],
+ "upsells": [{"href": "https://local.wordpress.test/wp-json/wc/store/v1/products?include=10,20", "embeddable": true}]
+ },
+ "_embedded": {
+ "upsells": [
+ {"id": 10, "name": "Upsell Product 1", "...": "..."},
+ {"id": 20, "name": "Upsell Product 2", "...": "..."}
+ ]
+ }
+}
+```
+
## Product Variations
By default, Store API excludes product variations. You can retrieve the variations for a product by using the `type=variation`.
diff --git a/plugins/woocommerce/changelog/62603-add-issue-62601 b/plugins/woocommerce/changelog/62603-add-issue-62601
new file mode 100644
index 0000000000..5ab92ad50a
--- /dev/null
+++ b/plugins/woocommerce/changelog/62603-add-issue-62601
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add embeddable _links for upsells, cross-sells, and related products to Store API Products endpoint.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index d9d6587df4..bf4c8284fc 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -494,7 +494,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return array
*/
public function get_upsell_ids( $context = 'view' ) {
- return $this->get_prop( 'upsell_ids', $context );
+ return array_map( 'absint', $this->get_prop( 'upsell_ids', $context ) );
}
/**
@@ -505,7 +505,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return array
*/
public function get_cross_sell_ids( $context = 'view' ) {
- return $this->get_prop( 'cross_sell_ids', $context );
+ return array_map( 'absint', $this->get_prop( 'cross_sell_ids', $context ) );
}
/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 96e23fa42a..36eb51e668 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -72606,12 +72606,6 @@ parameters:
count: 1
path: src/StoreApi/Routes/V1/AbstractCartRoute.php
- -
- message: '#^Call to an undefined method WP_Error\|WP_REST_Response\:\:add_links\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/StoreApi/Routes/V1/AbstractRoute.php
-
-
message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\AbstractRoute\:\:get_response\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
identifier: missingType.generics
@@ -72678,12 +72672,6 @@ parameters:
count: 1
path: src/StoreApi/Routes/V1/AbstractRoute.php
- -
- message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\AbstractRoute\:\:prepare_item_for_response\(\) should return WP_REST_Response but returns WP_Error\|WP_REST_Response\.$#'
- identifier: return.type
- count: 1
- path: src/StoreApi/Routes/V1/AbstractRoute.php
-
-
message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\AbstractRoute\:\:prepare_links\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
identifier: missingType.generics
@@ -73986,12 +73974,6 @@ parameters:
count: 1
path: src/StoreApi/Routes/V1/Products.php
- -
- message: '#^Parameter \#1 \$response of method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\AbstractRoute\:\:prepare_response_for_collection\(\) expects WP_REST_Response, WP_Error\|WP_REST_Response given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Routes/V1/Products.php
-
-
message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductsById\:\:get_route_response\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
identifier: missingType.generics
@@ -73999,8 +73981,8 @@ parameters:
path: src/StoreApi/Routes/V1/ProductsById.php
-
- message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductsById\:\:get_route_response\(\) should return WP_REST_Response but returns WP_Error\|WP_REST_Response\.$#'
- identifier: return.type
+ message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductsById\:\:prepare_links\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
+ identifier: missingType.generics
count: 1
path: src/StoreApi/Routes/V1/ProductsById.php
@@ -74023,8 +74005,8 @@ parameters:
path: src/StoreApi/Routes/V1/ProductsBySlug.php
-
- message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductsBySlug\:\:get_route_response\(\) should return WP_REST_Response but returns WP_Error\|WP_REST_Response\.$#'
- identifier: return.type
+ message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductsBySlug\:\:prepare_links\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
+ identifier: missingType.generics
count: 1
path: src/StoreApi/Routes/V1/ProductsBySlug.php
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php
index b8b5c9c896..b15b2b2029 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php
@@ -245,6 +245,11 @@ abstract class AbstractRoute implements RouteInterface {
*/
public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
$response = rest_ensure_response( $this->schema->get_item_response( $item ) );
+
+ if ( is_wp_error( $response ) ) {
+ return rest_convert_error_to_response( $response );
+ }
+
$response->add_links( $this->prepare_links( $item, $request ) );
return $response;
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Products.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Products.php
index 78dc6c309e..91fad8b3d7 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Products.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Products.php
@@ -7,11 +7,14 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
/**
* Products class.
*/
class Products extends AbstractRoute {
+ use ProductLinksTrait;
+
/**
* The route identifier.
*
@@ -78,7 +81,7 @@ class Products extends AbstractRoute {
$response_objects = [];
foreach ( $query_results['objects'] as $object ) {
- $data = rest_ensure_response( $this->schema->get_item_response( $object ) );
+ $data = $this->prepare_item_for_response( $object, $request );
$response_objects[] = $this->prepare_response_for_collection( $data );
}
@@ -96,32 +99,6 @@ class Products extends AbstractRoute {
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( $this->get_namespace() . $this->get_path() . '/' . $item->get_id() ),
- ),
- 'collection' => array(
- 'href' => rest_url( $this->get_namespace() . $this->get_path() ),
- ),
- );
-
- if ( $item->get_parent_id() ) {
- $links['up'] = array(
- 'href' => rest_url( $this->get_namespace() . $this->get_path() . '/' . $item->get_parent_id() ),
- );
- }
-
- return $links;
- }
-
/**
* Get the query params for collections of products.
*
@@ -461,6 +438,13 @@ class Products extends AbstractRoute {
'sanitize_callback' => 'wp_parse_id_list',
);
+ $params['related'] = array(
+ 'description' => __( 'Limit result set to products related to a specific product ID.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
return $params;
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
index c7d0e2843b..1ded0e2e6c 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
@@ -1,12 +1,15 @@
-<?php
+<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
/**
* ProductsById class.
*/
class ProductsById extends AbstractRoute {
+ use ProductLinksTrait;
+
/**
* The route identifier.
*
@@ -83,6 +86,6 @@ class ProductsById extends AbstractRoute {
throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 );
}
- return rest_ensure_response( $this->schema->get_item_response( $object ) );
+ return $this->prepare_item_for_response( $object, $request );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
index 55adb347dd..c1a040ec28 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
@@ -1,12 +1,15 @@
-<?php
+<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
/**
* ProductsBySlug class.
*/
class ProductsBySlug extends AbstractRoute {
+ use ProductLinksTrait;
+
/**
* The route identifier.
*
@@ -88,7 +91,7 @@ class ProductsBySlug extends AbstractRoute {
throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 );
}
- return rest_ensure_response( $this->schema->get_item_response( $object ) );
+ return $this->prepare_item_for_response( $object, $request );
}
/**
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractSchema.php
index 68d1e8e8bb..482de6ebfa 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractSchema.php
@@ -258,7 +258,7 @@ abstract class AbstractSchema {
return [
'type' => 'object',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'arg_options' => [
'default' => $defaults,
'validate_callback' => $this->get_recursive_validate_callback( $extended_schema ),
@@ -329,43 +329,43 @@ abstract class AbstractSchema {
'currency_code' => [
'description' => __( 'Currency code (in ISO format) for returned prices.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'currency_symbol' => [
'description' => __( 'Currency symbol for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'currency_minor_unit' => [
'description' => __( 'Currency minor unit (number of digits after the decimal separator) for returned prices.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'currency_decimal_separator' => array(
'description' => __( 'Decimal separator for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
- 'context' => array( 'view', 'edit' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'currency_thousand_separator' => array(
'description' => __( 'Thousand separator for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
- 'context' => array( 'view', 'edit' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'currency_prefix' => array(
'description' => __( 'Price prefix for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
- 'context' => array( 'view', 'edit' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'currency_suffix' => array(
'description' => __( 'Price prefix for the currency which can be used to format returned prices.', 'woocommerce' ),
'type' => 'string',
- 'context' => array( 'view', 'edit' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
];
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php
index 020e921626..30b0465962 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ImageAttachmentSchema.php
@@ -29,39 +29,39 @@ class ImageAttachmentSchema extends AbstractSchema {
'id' => [
'description' => __( 'Image ID.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'src' => [
'description' => __( 'Full size image URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'thumbnail' => [
'description' => __( 'Thumbnail URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'srcset' => [
'description' => __( 'Thumbnail srcset for responsive images.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'sizes' => [
'description' => __( 'Thumbnail sizes for responsive images.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'name' => [
'description' => __( 'Image name.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'alt' => [
'description' => __( 'Image alternative text.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
];
}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
index cd00bb186a..6882bdd012 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
@@ -54,68 +54,68 @@ class ProductSchema extends AbstractSchema {
'id' => [
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Product name.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'slug' => [
'description' => __( 'Product slug.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'parent' => [
'description' => __( 'ID of the parent product, if applicable.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'type' => [
'description' => __( 'Product type.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'variation' => [
'description' => __( 'Product variation attributes, if applicable.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'permalink' => [
'description' => __( 'Product URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'short_description' => [
'description' => __( 'Product short description in HTML format.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'description' => [
'description' => __( 'Product full description in HTML format.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'on_sale' => [
'description' => __( 'Is the product on sale?', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'sku' => [
'description' => __( 'Unique identifier.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
],
'prices' => [
'description' => __( 'Price data provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
'properties' => array_merge(
$this->get_store_currency_properties(),
@@ -123,37 +123,37 @@ class ProductSchema extends AbstractSchema {
'price' => [
'description' => __( 'Current product price.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'regular_price' => [
'description' => __( 'Regular product price.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'sale_price' => [
'description' => __( 'Sale product price, if applicable.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'price_range' => [
'description' => __( 'Price range, if applicable.', 'woocommerce' ),
'type' => [ 'object', 'null' ],
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
'properties' => [
'min_amount' => [
'description' => __( 'Price amount.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'max_amount' => [
'description' => __( 'Price amount.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -164,25 +164,25 @@ class ProductSchema extends AbstractSchema {
'price_html' => array(
'description' => __( 'Price string formatted as HTML.', 'woocommerce' ),
'type' => 'string',
- 'context' => array( 'view', 'edit' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'average_rating' => [
'description' => __( 'Reviews average rating.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'review_count' => [
'description' => __( 'Amount of reviews that the product has.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'images' => [
'description' => __( 'List of images.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => $this->image_attachment_schema->get_properties(),
@@ -191,32 +191,32 @@ class ProductSchema extends AbstractSchema {
'categories' => [
'description' => __( 'List of categories, if applicable.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Category ID', 'woocommerce' ),
'type' => 'number',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Category name', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'Category slug', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'link' => [
'description' => __( 'Category link', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -225,32 +225,32 @@ class ProductSchema extends AbstractSchema {
'tags' => [
'description' => __( 'List of tags, if applicable.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Tag ID', 'woocommerce' ),
'type' => 'number',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Tag name', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'Tag slug', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'link' => [
'description' => __( 'Tag link.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -259,32 +259,32 @@ class ProductSchema extends AbstractSchema {
'brands' => [
'description' => __( 'List of brands, if applicable.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Brand ID', 'woocommerce' ),
'type' => 'number',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'name' => [
'description' => __( 'Brand name', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'Brand slug', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'link' => [
'description' => __( 'Brand link', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -293,63 +293,63 @@ class ProductSchema extends AbstractSchema {
'attributes' => [
'description' => __( 'List of attributes (taxonomy terms) assigned to the product. For variable products, these are mapped to variations (see the `variations` field).', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The attribute name.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'taxonomy' => [
'description' => __( 'The attribute taxonomy, or null if the attribute is not taxonomy based.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'has_variations' => [
'description' => __( 'True if this attribute is used by product variations.', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'terms' => [
'description' => __( 'List of assigned attribute terms.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The term ID, or 0 if the attribute is not a global attribute.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The term name.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'The term slug.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'default' => [
'description' => __( 'If this is a default attribute', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -361,33 +361,33 @@ class ProductSchema extends AbstractSchema {
'variations' => [
'description' => __( 'List of variation IDs, if applicable.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'attributes' => [
'description' => __( 'List of variation attributes.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'type' => 'object',
'properties' => [
'name' => [
'description' => __( 'The attribute name.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'value' => [
'description' => __( 'The assigned attribute.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -399,54 +399,54 @@ class ProductSchema extends AbstractSchema {
'grouped_products' => [
'description' => __( 'List of grouped product IDs, if applicable.', 'woocommerce' ),
'type' => 'array',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'items' => [
'description' => __( 'List of grouped product ids.', 'woocommerce' ),
'type' => 'integer',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
'has_options' => [
'description' => __( 'Does the product have additional options before it can be added to the cart?', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'is_purchasable' => [
'description' => __( 'Is the product purchasable?', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'is_in_stock' => [
'description' => __( 'Is the product in stock?', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'is_on_backorder' => [
'description' => __( 'Is the product stock backordered? This will also return false if backorder notifications are turned off.', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'stock_availability' => [
'description' => __( 'Information about the product\'s availability.', 'woocommerce' ),
'type' => 'object',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
'properties' => [
'text' => [
'description' => __( 'Stock availability text.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'class' => [
'description' => __( 'Stock availability class.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
@@ -454,62 +454,62 @@ class ProductSchema extends AbstractSchema {
'low_stock_remaining' => [
'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ),
'type' => [ 'number', 'null' ],
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'sold_individually' => [
'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woocommerce' ),
'type' => 'boolean',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'add_to_cart' => [
'description' => __( 'Add to cart button parameters.', 'woocommerce' ),
'type' => 'object',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
'properties' => [
'text' => [
'description' => __( 'Button text.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'description' => [
'description' => __( 'Button description.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'url' => [
'description' => __( 'Add to cart URL.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'minimum' => [
'description' => __( 'The minimum quantity that can be added to the cart.', 'woocommerce' ),
'type' => 'number',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'maximum' => [
'description' => __( 'The maximum quantity that can be added to the cart.', 'woocommerce' ),
'type' => 'number',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
'multiple_of' => [
'description' => __( 'The amount that quantities increment by. Quantity must be an multiple of this value.', 'woocommerce' ),
'type' => 'number',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
'default' => 1,
],
'single_text' => [
'description' => __( 'Button text in the single product page.', 'woocommerce' ),
'type' => 'string',
- 'context' => [ 'view', 'edit' ],
+ 'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductLinksTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductLinksTrait.php
new file mode 100644
index 0000000000..92bf818825
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductLinksTrait.php
@@ -0,0 +1,72 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities;
+
+/**
+ * ProductLinksTrait
+ *
+ * Shared functionality for preparing product links including embeddable links for upsells, cross-sells, and related products.
+ */
+trait ProductLinksTrait {
+ /**
+ * Prepare links for the request.
+ *
+ * @param \WC_Product $item Product object.
+ * @param \WP_REST_Request $request Request object.
+ * @return array
+ *
+ * @since 10.6.0
+ */
+ protected function prepare_links( $item, $request ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( $this->get_namespace() . '/products/' . $item->get_id() ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( $this->get_namespace() . '/products' ),
+ ),
+ );
+
+ if ( $item->get_parent_id() ) {
+ $links['up'] = array(
+ 'href' => rest_url( $this->get_namespace() . '/products/' . $item->get_parent_id() ),
+ );
+ }
+
+ $upsell_ids = $item->get_upsell_ids();
+ if ( ! empty( $upsell_ids ) ) {
+ $links['upsells'] = array(
+ 'href' => add_query_arg(
+ array( 'include' => implode( ',', $upsell_ids ) ),
+ rest_url( $this->get_namespace() . '/products' )
+ ),
+ 'embeddable' => true,
+ );
+ }
+
+ $cross_sell_ids = $item->get_cross_sell_ids();
+ if ( ! empty( $cross_sell_ids ) ) {
+ $links['cross_sells'] = array(
+ 'href' => add_query_arg(
+ array( 'include' => implode( ',', $cross_sell_ids ) ),
+ rest_url( $this->get_namespace() . '/products' )
+ ),
+ 'embeddable' => true,
+ );
+ }
+
+ $links['related'] = array(
+ 'href' => add_query_arg(
+ array(
+ 'related' => $item->get_id(),
+ 'per_page' => 10,
+ ),
+ rest_url( $this->get_namespace() . '/products' )
+ ),
+ 'embeddable' => true,
+ );
+
+ return $links;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
index 2db3c1136d..82d3edd7fe 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
@@ -251,6 +251,22 @@ class ProductQuery implements QueryClausesGenerator {
$args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore
}
+ // Filter by related products.
+ if ( ! empty( $request['related'] ) ) {
+ $product_id = absint( $request['related'] );
+ $limit = ! empty( $request['per_page'] ) ? (int) $request['per_page'] : 100;
+ $related = wc_get_related_products( $product_id, $limit );
+
+ if ( ! empty( $related ) ) {
+ $args['post__in'] = ! empty( $args['post__in'] )
+ ? array_values( array_intersect( $args['post__in'], $related ) )
+ : array_values( $related );
+ } else {
+ // No related products found, return empty result.
+ $args['post__in'] = array( 0 );
+ }
+ }
+
return $args;
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
index 2681cc9ded..a47f9c8ccc 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
@@ -209,6 +209,7 @@ class Products extends ControllerTestCase {
$this->assertArrayHasKey( 'attributes', $params );
$this->assertArrayHasKey( 'catalog_visibility', $params );
$this->assertArrayHasKey( 'rating', $params );
+ $this->assertArrayHasKey( 'related', $params );
}
/**
@@ -288,4 +289,257 @@ class Products extends ControllerTestCase {
$this->assertNull( $data['image'] );
}
+
+ /**
+ * @testdox Single product response should include self and collection _links.
+ */
+ public function test_single_product_has_self_and_collection_links() {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $this->products[0]->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'self', $links );
+ $this->assertArrayHasKey( 'collection', $links );
+ $this->assertStringContainsString( '/wc/store/v1/products/' . $this->products[0]->get_id(), $links['self'][0]['href'] );
+ $this->assertStringContainsString( '/wc/store/v1/products', $links['collection'][0]['href'] );
+ }
+
+ /**
+ * @testdox Product with upsells should include embeddable upsells link.
+ */
+ public function test_product_with_upsells_has_embeddable_upsells_link() {
+ $fixtures = new FixtureData();
+ $upsell_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Upsell Product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 20,
+ )
+ );
+
+ $main_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Main Product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 10,
+ 'upsell_ids' => array( $upsell_product->get_id() ),
+ )
+ );
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $main_product->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'upsells', $links );
+ $this->assertStringContainsString( 'include=' . $upsell_product->get_id(), $links['upsells'][0]['href'] );
+ $this->assertArrayHasKey( 'embeddable', $links['upsells'][0]['attributes'] );
+ $this->assertTrue( $links['upsells'][0]['attributes']['embeddable'] );
+ }
+
+ /**
+ * @testdox Product with cross-sells should include embeddable cross_sells link.
+ */
+ public function test_product_with_cross_sells_has_embeddable_cross_sells_link() {
+ $fixtures = new FixtureData();
+ $cross_sell_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Cross-sell Product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 15,
+ )
+ );
+
+ $main_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Main Product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 10,
+ 'cross_sell_ids' => array( $cross_sell_product->get_id() ),
+ )
+ );
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $main_product->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'cross_sells', $links );
+ $this->assertStringContainsString( 'include=' . $cross_sell_product->get_id(), $links['cross_sells'][0]['href'] );
+ $this->assertArrayHasKey( 'embeddable', $links['cross_sells'][0]['attributes'] );
+ $this->assertTrue( $links['cross_sells'][0]['attributes']['embeddable'] );
+ }
+
+ /**
+ * @testdox Product without upsells should not include upsells link.
+ */
+ public function test_product_without_upsells_has_no_upsells_link() {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $this->products[0]->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayNotHasKey( 'upsells', $links );
+ }
+
+ /**
+ * @testdox Product without cross-sells should not include cross_sells link.
+ */
+ public function test_product_without_cross_sells_has_no_cross_sells_link() {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $this->products[0]->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayNotHasKey( 'cross_sells', $links );
+ }
+
+ /**
+ * @testdox Collection endpoint should return products with _links.
+ */
+ public function test_collection_endpoint_returns_links() {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertGreaterThan( 0, count( $data ) );
+
+ foreach ( $data as $product ) {
+ $this->assertArrayHasKey( '_links', $product );
+ $this->assertArrayHasKey( 'self', $product['_links'] );
+ $this->assertArrayHasKey( 'collection', $product['_links'] );
+ }
+ }
+
+ /**
+ * @testdox Context parameter should accept embed value.
+ */
+ public function test_context_accepts_embed_value() {
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $this->products[0]->get_id() );
+ $request->set_param( 'context', 'embed' );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * @testdox Product variation should include up link to parent product.
+ */
+ public function test_product_variation_has_up_link() {
+ $fixtures = new FixtureData();
+ $attribute = FixtureData::get_product_attribute( 'color', array( 'red', 'blue' ) );
+
+ $variable_product = $fixtures->get_variable_product(
+ array(
+ 'name' => 'Variable Product',
+ ),
+ array( $attribute )
+ );
+
+ $variation = $fixtures->get_variation_product(
+ $variable_product->get_id(),
+ array( 'pa_color' => 'red' ),
+ array(
+ 'regular_price' => 10,
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $variation->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'up', $links );
+ $this->assertStringContainsString( '/wc/store/v1/products/' . $variable_product->get_id(), $links['up'][0]['href'] );
+ }
+
+ /**
+ * @testdox Product should always include embeddable related link using related parameter format.
+ */
+ public function test_product_has_related_link_with_related_parameter_format() {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $this->products[0]->get_id() ) );
+ $links = $response->get_links();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'related', $links );
+ $this->assertStringContainsString( 'related=' . $this->products[0]->get_id(), $links['related'][0]['href'] );
+ $this->assertStringContainsString( 'per_page=10', $links['related'][0]['href'] );
+ $this->assertStringNotContainsString( 'include=', $links['related'][0]['href'] );
+ $this->assertArrayHasKey( 'embeddable', $links['related'][0]['attributes'] );
+ $this->assertTrue( $links['related'][0]['attributes']['embeddable'] );
+ }
+
+ /**
+ * @testdox Related query parameter should filter products to related products.
+ */
+ public function test_related_query_parameter_filters_products() {
+ $fixtures = new FixtureData();
+
+ // Create products in the same category so they are related.
+ $term = wp_insert_term( 'Related Category', 'product_cat' );
+
+ $main_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Main Related Product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 10,
+ )
+ );
+ wp_set_object_terms( $main_product->get_id(), $term['term_id'], 'product_cat' );
+
+ $related_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Related Product In Same Category',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 15,
+ )
+ );
+
+ wp_set_object_terms( $related_product->get_id(), $term['term_id'], 'product_cat' );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'related', $main_product->get_id() );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $product_ids = array_map(
+ function ( $product ) {
+ return $product['id'];
+ },
+ $data
+ );
+
+ // Main product should not be in its own related products.
+ $this->assertNotContains( $main_product->get_id(), $product_ids );
+
+ // Related product should be returned.
+ $this->assertContains( $related_product->get_id(), $product_ids );
+ }
+
+ /**
+ * @testdox Related query parameter returns empty when no related products exist.
+ */
+ public function test_related_query_parameter_returns_empty_when_no_related() {
+ $fixtures = new FixtureData();
+
+ // Create a product with unique category (no other products).
+ $term = wp_insert_term( 'Unique Category ' . uniqid(), 'product_cat' );
+
+ $lonely_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Lonely Product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 10,
+ )
+ );
+ wp_set_object_terms( $lonely_product->get_id(), $term['term_id'], 'product_cat' );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+ $request->set_param( 'related', $lonely_product->get_id() );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 0, $response->get_data() );
+ }
}