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.
*