Commit e38e868ae1e for woocommerce
commit e38e868ae1e1554633bceaa1e4b232ff9a6615b2
Author: Francesco <frosso@users.noreply.github.com>
Date: Fri Sep 4 17:33:32 2026 +0200
fix: return the order endpoint's item_data as a list (#68161)
diff --git a/docs/apis/store-api/resources-endpoints/order.md b/docs/apis/store-api/resources-endpoints/order.md
index 6cd77ae20f8..797c4f73b75 100644
--- a/docs/apis/store-api/resources-endpoints/order.md
+++ b/docs/apis/store-api/resources-endpoints/order.md
@@ -20,6 +20,8 @@ Returns the full order object response (see [Order Response](#order-response)).
Order endpoints return responses in the same format as `/cart`; an order object which includes order items, applied coupons, shipping addresses and rates, and non-sensitive customer data.
+One difference worth calling out is `item_data`. Order items carry stored order item metadata; cart items carry display data from the `woocommerce_get_item_data` filter. Both are lists, but the entries hold different properties. See [Item data](#item-data).
+
### Order Response
```json
@@ -118,7 +120,15 @@ Order endpoints return responses in the same format as `/cart`; an order object
"sale_price": "10000000"
}
},
- "item_data": [],
+ "item_data": [
+ {
+ "id": 1234,
+ "key": "Gift message",
+ "value": "Happy birthday",
+ "display_key": "Gift message",
+ "display_value": "<p>Happy birthday</p>\n"
+ }
+ ],
"totals": {
"currency_code": "GBP",
"currency_symbol": "£",
@@ -223,6 +233,22 @@ Order endpoints return responses in the same format as `/cart`; an order object
}
```
+### Item data
+
+Each order item carries its metadata in `item_data`, a list of entries:
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `id` | integer \| null | The order item metadata row ID. `null` when an extension added the entry through `woocommerce_order_item_get_formatted_meta_data` without a stored row behind it. |
+| `key` | string | Metadata key. |
+| `value` | string | Metadata value. |
+| `display_key` | string | Key, formatted for display. |
+| `display_value` | string | Value, formatted for display. |
+
+Most stores return an empty list. Entries appear when an extension writes metadata to the line item, as Product Add-Ons, Deposits, Bundles and Gift Cards do.
+
+Extensions can add properties beyond the five above through the same filter. The endpoint passes them through.
+
### Error Response
If an order action cannot be performed, an error response will be returned. This will include a reason code and an error message:
diff --git a/plugins/woocommerce/changelog/fix-store-api-order-item-data-schema b/plugins/woocommerce/changelog/fix-store-api-order-item-data-schema
new file mode 100644
index 00000000000..26b26e5d647
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-order-item-data-schema
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Store API: the order endpoint now returns item_data as a list, matching the cart endpoint and its own documented schema. Each entry carries its metadata row ID in a new `id` property, null when an extension added the entry without a stored row.
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
index 56becf4ae58..270dbe13a55 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
@@ -30,6 +30,108 @@ class OrderItemSchema extends ItemSchema {
*/
const IDENTIFIER = 'order-item';
+ /**
+ * Item schema properties.
+ *
+ * The inherited `item_data` describes the cart's display data. Order items carry stored meta
+ * rows, which have different properties.
+ *
+ * @return array
+ *
+ * @since 11.2.0
+ */
+ public function get_properties() {
+ $properties = parent::get_properties();
+
+ $properties['item_data'] = [
+ 'description' => __( 'Metadata related to the item.', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => [
+ 'description' => __( 'Order item metadata ID. Null for entries an extension added that have no stored metadata row.', 'woocommerce' ),
+ 'type' => [ 'integer', 'null' ],
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ ],
+ 'key' => [
+ 'description' => __( 'Metadata key.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ ],
+ 'value' => [
+ 'description' => __( 'Metadata value.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ ],
+ 'display_key' => [
+ 'description' => __( 'Metadata key, formatted for display.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ ],
+ 'display_value' => [
+ 'description' => __( 'Metadata value, formatted for display.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => [ 'view', 'edit' ],
+ 'readonly' => true,
+ ],
+ ],
+ ],
+ ];
+
+ return $properties;
+ }
+
+ /**
+ * Get order item metadata as a list.
+ *
+ * Keyed by meta row ID at source; the Store API sends a list, so the ID moves into `id`.
+ *
+ * @param \WC_Order_Item $order_item Order item instance.
+ * @return array
+ */
+ private function get_item_data( $order_item ) {
+ $formatted_meta_data = $order_item->get_all_formatted_meta_data();
+ $item_data = [];
+
+ // A `woocommerce_order_item_get_formatted_meta_data` callback can return anything, and a bad
+ // one must not take the endpoint down.
+ if ( ! is_array( $formatted_meta_data ) ) {
+ return $item_data;
+ }
+
+ // A callback appending with `$formatted_meta[] =` gets PHP's next integer key, not a row ID.
+ // Same source the formatted metadata is built from, so matching costs no query.
+ $meta_row_ids = array_flip( array_filter( wp_list_pluck( $order_item->get_meta_data(), 'id' ) ) );
+
+ foreach ( $formatted_meta_data as $meta_id => $meta ) {
+ // Only public fields are meant to ship. Casting an object reaches past them and
+ // publishes mangled names, so let it serialize itself first, then read what is public.
+ if ( $meta instanceof \JsonSerializable ) {
+ $meta = $meta->jsonSerialize();
+ }
+
+ if ( is_object( $meta ) ) {
+ $meta = get_object_vars( $meta );
+ }
+
+ if ( ! is_array( $meta ) ) {
+ continue;
+ }
+
+ // Union keeps the left operand, so a callback's own `id` cannot shadow the row ID.
+ $item_data[] = [ 'id' => isset( $meta_row_ids[ $meta_id ] ) ? $meta_id : null ] + $meta;
+ }
+
+ return $item_data;
+ }
+
/**
* Get order items data.
*
@@ -110,7 +212,7 @@ class OrderItemSchema extends ItemSchema {
'permalink' => $product_properties['permalink'],
'images' => $product_properties['images'],
'variation' => $product_properties['variation'],
- 'item_data' => $order_item->get_all_formatted_meta_data(),
+ 'item_data' => $this->get_item_data( $order_item ),
'prices' => (object) $product_properties['prices'],
'totals' => (object) $this->prepare_currency_response( $this->get_totals( $order_item ) ),
'catalog_visibility' => $product_properties['catalog_visibility'],
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
index 2ea7d7db47f..803faeb7153 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Order.php
@@ -7,6 +7,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\ValidateSchema;
+
/**
* Order Route Tests.
*
@@ -134,6 +136,359 @@ class Order extends ControllerTestCase {
return $order;
}
+ /**
+ * The Order route has no other schema validation coverage.
+ *
+ * The item needs metadata, or the nested item_data schema is never exercised.
+ *
+ * @testdox Order response matches the published schema.
+ */
+ public function test_response_matches_schema(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $controller = $routes->get( 'order', 'v1' );
+ $validate = new ValidateSchema( $controller->get_item_schema() );
+
+ $data = $response->get_data();
+ $diff = $validate->get_diff_from_object( $data );
+
+ // Other mismatches on this response are out of scope (items' `type` and `extensions`,
+ // `quantity_limits`, `fees`). Filter to item_data so this does not snapshot them.
+ $item_data_diff = array_values(
+ array_filter(
+ array_merge( $diff['missing'] ?? array(), $diff['invalid_type'] ?? array(), $diff['no_schema'] ?? array() ),
+ function ( $entry ) {
+ return false !== strpos( $entry, 'item_data' );
+ }
+ )
+ );
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+ $this->assertEmpty( $item_data_diff, print_r( $item_data_diff, true ) );
+
+ $item_data = $data['items'][0]['item_data'];
+ $this->assertNotEmpty( $item_data );
+
+ // ValidateSchema only recurses into the first entry, so pin the exact public shape here.
+ $entry = $item_data[0];
+ $this->assertEqualSets( array( 'id', 'key', 'value', 'display_key', 'display_value' ), array_keys( $entry ) );
+ $this->assertSame( 'Gift message', $entry['key'] );
+ $this->assertSame( 'Happy birthday', $entry['value'] );
+ }
+
+ /**
+ * Consumers read this as a list, the way the cart endpoint sends it.
+ *
+ * @testdox Order item_data serializes as a JSON list carrying the meta row ID.
+ */
+ public function test_item_data_serializes_as_a_json_list_carrying_the_meta_id(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+ $meta_id = current( $item->get_meta_data() )->id;
+
+ $this->assertSame( array( 0 ), array_keys( $item_data ), 'item_data must be a list, not keyed by meta ID.' );
+ $this->assertStringStartsWith( '[', wp_json_encode( $item_data ), 'item_data must serialize as a JSON list, not an object.' );
+ $this->assertSame( $meta_id, $item_data[0]['id'], 'The meta row ID must survive as `id`.' );
+ }
+
+ /**
+ * Callbacks can add their own fields; reshaping the container must not drop them.
+ *
+ * @testdox Order item_data keeps fields added by extensions.
+ */
+ public function test_item_data_keeps_fields_added_by_extensions(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function ( $formatted_meta ) {
+ foreach ( $formatted_meta as $meta ) {
+ $meta->custom_field = 'from-extension';
+ }
+ return $formatted_meta;
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $entry = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'][0];
+
+ $this->assertSame( 'from-extension', $entry['custom_field'] ?? null, 'Extension-added fields must survive.' );
+ $this->assertSame( current( $item->get_meta_data() )->id, $entry['id'], 'The row ID must win over anything a callback sets.' );
+ }
+
+ /**
+ * A misbehaving callback should cost the endpoint its metadata, not its response.
+ *
+ * @testdox Order endpoint survives a callback that returns a non-array.
+ */
+ public function test_item_data_survives_a_hostile_filter(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function () {
+ return 'not-an-array';
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), 'A bad callback must not fail the request.' );
+ $this->assertSame( [], $response->get_data()['items'][0]['item_data'] );
+ }
+
+ /**
+ * @testdox Order item_data skips metadata entries that are not objects or arrays.
+ */
+ public function test_item_data_skips_non_object_entries(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function ( $formatted_meta ) {
+ $formatted_meta[999] = 'scalar-entry';
+ return $formatted_meta;
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+ $this->assertCount( 1, $item_data, 'The scalar entry must be skipped, the real one kept.' );
+ $this->assertSame( 'Gift message', $item_data[0]['key'] );
+ }
+
+ /**
+ * Still metadata the store wants shown, so it is published with a null `id` rather than dropped.
+ *
+ * @testdox Order item_data reports a null id for an entry not keyed by a meta row ID.
+ */
+ public function test_item_data_reports_null_id_for_entries_not_keyed_by_a_meta_row_id(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function ( $formatted_meta ) {
+ $formatted_meta['not-a-row-id'] = (object) array(
+ 'key' => 'Injected',
+ 'value' => 'value',
+ 'display_key' => 'Injected',
+ 'display_value' => 'value',
+ );
+ return $formatted_meta;
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+ $this->assertCount( 2, $item_data, 'The entry must be kept, not dropped.' );
+ $this->assertSame( current( $item->get_meta_data() )->id, $item_data[0]['id'] );
+ $this->assertSame( 'Injected', $item_data[1]['key'] );
+ $this->assertNull( $item_data[1]['id'], 'An entry with no stored row has no ID to report.' );
+ }
+
+ /**
+ * `WC_Meta_Data` keeps its fields protected, so casting one publishes mangled property names.
+ * Trunk serialized it through `JsonSerializable`, and so must this.
+ *
+ * @testdox Order item_data serializes an appended WC_Meta_Data through JsonSerializable.
+ */
+ public function test_item_data_serializes_an_appended_wc_meta_data(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function ( $formatted_meta ) {
+ $formatted_meta[] = new \WC_Meta_Data(
+ array(
+ 'id' => 0,
+ 'key' => 'Appended',
+ 'value' => 'value',
+ )
+ );
+ return $formatted_meta;
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+ $this->assertSame( 'Appended', $item_data[1]['key'] );
+ $this->assertSame( 'value', $item_data[1]['value'] );
+ $this->assertNull( $item_data[1]['id'], 'The wrapped id is 0, not a row on this item.' );
+ $this->assertStringNotContainsString( "\0", wp_json_encode( $item_data ), 'Protected property names must not reach the response.' );
+ }
+
+ /**
+ * An extension's own object need not offer `JsonSerializable`, and casting one publishes its
+ * non-public fields under mangled names.
+ *
+ * @testdox Order item_data publishes only the public fields of an appended plain object.
+ */
+ public function test_item_data_publishes_only_public_fields_of_an_appended_object(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function ( $formatted_meta ) {
+ $formatted_meta[] = new class() {
+ /**
+ * Metadata key.
+ *
+ * @var string
+ */
+ public $key = 'Appended';
+
+ /**
+ * Metadata value.
+ *
+ * @var string
+ */
+ public $value = 'value';
+
+ /**
+ * Metadata key, formatted for display.
+ *
+ * @var string
+ */
+ public $display_key = 'Appended';
+
+ /**
+ * Metadata value, formatted for display.
+ *
+ * @var string
+ */
+ public $display_value = 'value';
+
+ /**
+ * A field the extension keeps to itself.
+ *
+ * @var string
+ */
+ protected $internal = 'internal';
+ };
+ return $formatted_meta;
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+ $this->assertSame( 'Appended', $item_data[1]['key'] );
+ $this->assertSame( array( 'id', 'key', 'value', 'display_key', 'display_value' ), array_keys( $item_data[1] ), 'Only public fields belong in the response.' );
+ }
+
+ /**
+ * Appending gets PHP's next integer key. Publishing that as `id` would point consumers at
+ * another item's row.
+ *
+ * @testdox Order item_data reports a null id for an appended entry.
+ */
+ public function test_item_data_reports_null_id_for_an_appended_entry(): void {
+ $order = $this->create_guest_order();
+ $item = current( $order->get_items() );
+ $item->add_meta_data( 'Gift message', 'Happy birthday', true );
+ $item->save();
+
+ add_filter(
+ 'woocommerce_order_item_get_formatted_meta_data',
+ function ( $formatted_meta ) {
+ $formatted_meta[] = (object) array(
+ 'key' => 'Appended',
+ 'value' => 'value',
+ 'display_key' => 'Appended',
+ 'display_value' => 'value',
+ );
+ return $formatted_meta;
+ }
+ );
+
+ wp_set_current_user( 0 );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/store/v1/order/' . $order->get_id() );
+ $request->set_param( 'key', $order->get_order_key() );
+ $request->set_param( 'billing_email', $order->get_billing_email() );
+
+ $item_data = rest_get_server()->dispatch( $request )->get_data()['items'][0]['item_data'];
+
+ $this->assertCount( 2, $item_data, 'The appended entry must be kept, not dropped.' );
+ $this->assertSame( 'Appended', $item_data[1]['key'] );
+ $this->assertNull( $item_data[1]['id'], 'An appended entry has no stored row, so no ID.' );
+ }
+
+
/**
* Test that a guest can access a guest order with valid order key and billing email.
*/