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() );
+	}
 }