Commit 6e401e64139 for woocommerce

commit 6e401e6413989242645ca1b52a7da59c9d8da841
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Wed May 6 16:11:57 2026 +0200

    Add embedded variation links to products API (#64566)

    * Add embedded variation links to products API

    * Add changelog for variation embeds

    * improve code

    * Align variation embeds with Core behavior

    * lint code

    * Lint code

    Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

    * lint code

    * update changelog

    * lint code

    * fix readme

    * add comment

    * update changelog

    ---------

    Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/add-v4-rest-api-variations b/plugins/woocommerce/changelog/add-v4-rest-api-variations
new file mode 100644
index 00000000000..25c0ebea730
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-v4-rest-api-variations
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Add embedded product variation links to the WC REST API v4 products endpoint.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index dd0d89107c5..7507a4dea7e 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -241,6 +241,30 @@ class Controller extends WC_REST_Products_V2_Controller {
 		return new WP_REST_Response( $response_data, 200 );
 	}

+	/**
+	 * Prepare links for the request.
+	 *
+	 * @param \WC_Product                          $product  Object data.
+	 * @param WP_REST_Request<array<string,mixed>> $request Request object.
+	 * @return array Links for the given post.
+	 */
+	protected function prepare_links( $product, $request ) {
+		$links = parent::prepare_links( $product, $request );
+
+		if ( $product->is_type( ProductType::VARIABLE ) && $product->has_child() ) {
+			$links['variations'] = array();
+
+			foreach ( $product->get_children() as $variation_id ) {
+				$links['variations'][] = array(
+					'href'       => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $variation_id ) ),
+					'embeddable' => true,
+				);
+			}
+		}
+
+		return $links;
+	}
+
 	/**
 	 * Get the images for a product or product variation.
 	 *
@@ -1976,7 +2000,76 @@ class Controller extends WC_REST_Products_V2_Controller {
 			'readonly'    => true,
 		);

-			return $this->add_additional_fields_schema( $schema );
+		$schema = $this->add_embed_context_to_schema( $schema );
+
+		return $this->add_additional_fields_schema( $schema );
+	}
+
+	/**
+	 * Add the embed context to product schema properties available in view context.
+	 *
+	 * @param array $schema Product schema.
+	 * @return array Product schema with embed context support.
+	 */
+	private function add_embed_context_to_schema( array $schema ): array {
+		if ( empty( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) {
+			return $schema;
+		}
+
+		// WordPress REST embeds cannot be narrowed with _fields. Keep all non-sensitive
+		// view fields embeddable for now; scalability is tracked in https://github.com/woocommerce/woocommerce/issues/64652.
+		foreach ( $schema['properties'] as $property => $property_schema ) {
+			if ( ! is_array( $property_schema ) ) {
+				continue;
+			}
+
+			if ( in_array( $property, self::SENSITIVE_FIELDS, true ) ) {
+				continue;
+			}
+
+			$schema['properties'][ $property ] = $this->add_embed_context_to_schema_property( $property_schema );
+		}
+
+		return $schema;
+	}
+
+	/**
+	 * Add the embed context to a schema property and its nested properties.
+	 *
+	 * @param array $property_schema Schema property.
+	 * @return array Schema property with embed context support.
+	 */
+	private function add_embed_context_to_schema_property( array $property_schema ): array {
+		if (
+			! empty( $property_schema['context'] ) &&
+			is_array( $property_schema['context'] ) &&
+			in_array( 'view', $property_schema['context'], true ) &&
+			! in_array( 'embed', $property_schema['context'], true )
+		) {
+			$property_schema['context'][] = 'embed';
+		}
+
+		if ( ! empty( $property_schema['properties'] ) && is_array( $property_schema['properties'] ) ) {
+			foreach ( $property_schema['properties'] as $property => $nested_property_schema ) {
+				if ( ! is_array( $nested_property_schema ) ) {
+					continue;
+				}
+
+				$property_schema['properties'][ $property ] = $this->add_embed_context_to_schema_property( $nested_property_schema );
+			}
+		}
+
+		if ( ! empty( $property_schema['items']['properties'] ) && is_array( $property_schema['items']['properties'] ) ) {
+			foreach ( $property_schema['items']['properties'] as $property => $nested_property_schema ) {
+				if ( ! is_array( $nested_property_schema ) ) {
+					continue;
+				}
+
+				$property_schema['items']['properties'][ $property ] = $this->add_embed_context_to_schema_property( $nested_property_schema );
+			}
+		}
+
+		return $property_schema;
 	}

 	/**
@@ -2156,9 +2249,10 @@ class Controller extends WC_REST_Products_V2_Controller {
 		if ( $product->is_downloadable() || 'edit' === $context ) {
 			foreach ( $product->get_downloads() as $file_id => $file ) {
 				$downloads[] = array(
-					'id'   => $file_id, // MD5 hash.
-					'name' => $file['name'],
-					'file' => $file['file'],
+					'id'                           => $file_id,
+					// MD5 hash.
+											'name' => $file['name'],
+					'file'                         => $file['file'],
 				);
 			}
 		}
@@ -2253,7 +2347,8 @@ class Controller extends WC_REST_Products_V2_Controller {
 			$tags,
 			$exclude_ids,
 			$limit,
-			null // No need to pass the product ID.
+			null
+			// No need to pass the product ID.
 		);

 		// When no suggested products are found, return an empty array.
@@ -2272,8 +2367,8 @@ class Controller extends WC_REST_Products_V2_Controller {
 	 * (doesn't fire hooks, ensure_response, or add links).
 	 *
 	 * @param WC_Data         $object_data Object data.
-	 * @param WP_REST_Request $request Request object.
-	 * @param string          $context Request context.
+	 * @param WP_REST_Request $request     Request object.
+	 * @param string          $context     Request context.
 	 * @return array Product data to be included in the response.
 	 */
 	protected function prepare_object_for_response_core( $object_data, $request, $context ): array {
@@ -2291,6 +2386,7 @@ class Controller extends WC_REST_Products_V2_Controller {
 		);

 		$post_type_object = get_post_type_object( 'product' );
+
 		if ( $post_type_object instanceof \WP_Post_Type && ! current_user_can( $post_type_object->cap->read_private_posts ) ) {
 			foreach ( self::SENSITIVE_FIELDS as $field ) {
 				unset( $data[ $field ] );
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
index aa2e66176f3..66fb375bcf9 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
@@ -24,6 +24,14 @@ As discussed in the team conversation:

 ## Change Log

+### 2026-05-05 - Add embedded variation links
+
+**Summary**: Added embeddable `variations` links to variable product responses so child variations can be embedded when requesting products with `_embed=1`. The product schema now exposes the `embed` context for fields that are already available in `view` context, while sensitive fields such as downloads, metadata, purchase notes, and cost of goods sold remain excluded from embedded variation responses.
+
+**PR**: [#64566](https://github.com/woocommerce/woocommerce/pull/64566)
+
+**Breaking Changes**: None
+
 ### 2025-11-07 - Make the GET `/wc/v4/products/<id>` endpoint available to users with `edit_posts` capabilities

 **Summary**: The GET `/wc/v4/products/<id>` REST endpoint has been updated so that users who have the `edit_posts` capability (for example Authors) can retrieve published, non-password-protected products. Access remains restricted for users without either the `edit_posts` or `read_private_products` capabilities.
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index febf312589b..9fa4a9c6a62 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -305,6 +305,139 @@ class ProductsControllerTest extends WC_REST_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Variable product responses include embeddable variation links.
+	 */
+	public function test_variable_product_response_includes_embeddable_variation_links(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$variation_ids = $product->get_children();
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/products/' . $product->get_id() ) );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$links = $response->get_links();
+		$this->assertArrayHasKey( 'variations', $links, 'Variable products should include variation links.' );
+		$this->assertCount( count( $variation_ids ), $links['variations'], 'Variation links should match child variations.' );
+
+		foreach ( $variation_ids as $index => $variation_id ) {
+			$link_attributes = $links['variations'][ $index ]['attributes'] ?? $links['variations'][ $index ];
+
+			$this->assertStringContainsString( '/wc/v4/products/' . $variation_id, $links['variations'][ $index ]['href'] );
+			$this->assertTrue( $link_attributes['embeddable'], 'Variation links should be embeddable.' );
+		}
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Variable product responses embed variations when requested.
+	 */
+	public function test_variable_product_response_embeds_variations_when_requested(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$variation_ids = $product->get_children();
+		$request       = new WP_REST_Request( 'GET', '/wc/v4/products/' . $product->get_id() );
+		$request->set_param( '_embed', 1 );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$response_data = $this->server->response_to_data( $response, true );
+		$this->assertArrayHasKey( '_embedded', $response_data );
+		$this->assertArrayHasKey( 'variations', $response_data['_embedded'] );
+
+		$embedded_variation_ids = wp_list_pluck( $response_data['_embedded']['variations'], 'id' );
+		$this->assertEqualsCanonicalizing( $variation_ids, $embedded_variation_ids );
+
+		foreach ( $response_data['_embedded']['variations'] as $embedded_variation ) {
+			$this->assertArrayHasKey( '_links', $embedded_variation, 'Embedded variations should include REST links.' );
+			$this->assertArrayHasKey( 'self', $embedded_variation['_links'], 'Embedded variations should include a self link.' );
+			$this->assertArrayHasKey( 'up', $embedded_variation['_links'], 'Embedded variations should include a parent product link.' );
+		}
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Variation embed links do not propagate parent requested fields.
+	 */
+	public function test_variation_embed_links_do_not_propagate_parent_requested_fields(): void {
+		$product = WC_Helper_Product::create_variation_product();
+		$request = new WP_REST_Request( 'GET', '/wc/v4/products/' . $product->get_id() );
+		$request->set_param( '_fields', 'id,name,_links,_embedded' );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$links = $response->get_links();
+
+		$this->assertArrayHasKey( 'variations', $links );
+		$this->assertStringNotContainsString( '_fields=', $links['variations'][0]['href'] );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Simple product responses do not include variation links.
+	 */
+	public function test_simple_product_response_does_not_include_variation_links(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/products/' . $product->get_id() ) );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertArrayNotHasKey( 'variations', $response->get_links() );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Variation parent links are not embeddable.
+	 */
+	public function test_variation_parent_link_is_not_embeddable(): void {
+		$product      = WC_Helper_Product::create_variation_product();
+		$variation_id = $product->get_children()[0];
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/products/' . $variation_id ) );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$links = $response->get_links();
+		$this->assertArrayHasKey( 'up', $links );
+		$this->assertArrayNotHasKey( 'embeddable', $links['up'][0]['attributes'] ?? $links['up'][0] );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Product schema exposes embed context only for allowed fields.
+	 */
+	public function test_product_schema_exposes_embed_context_for_allowed_fields_only(): void {
+		$this->enable_cogs_feature();
+
+		$request  = new WP_REST_Request( 'OPTIONS', '/wc/v4/products' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$properties = $response->get_data()['schema']['properties'];
+
+		foreach ( array( 'id', 'name', 'price', 'variations', 'add_to_cart' ) as $property ) {
+			$this->assertContains( 'embed', $properties[ $property ]['context'], "{$property} should be available in embed context." );
+		}
+
+		$this->assertContains( 'embed', $properties['dimensions']['properties']['length']['context'] );
+		$this->assertContains( 'embed', $properties['add_to_cart']['properties']['url']['context'] );
+
+		foreach ( array( 'cost_of_goods_sold', 'downloads', 'download_limit', 'download_expiry', 'meta_data', 'purchase_note' ) as $property ) {
+			if ( isset( $properties[ $property ]['context'] ) ) {
+				$this->assertNotContains( 'embed', $properties[ $property ]['context'], "{$property} should not be available in embed context." );
+			}
+		}
+	}
+
 	/**
 	 * Test that the `search` parameter does partial matching in the product name, but not the SKU.
 	 *