Commit 1a043a4b3f0 for woocommerce
commit 1a043a4b3f05b36e4427736ff017a989a2c7fdc3
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Wed May 13 15:39:04 2026 +0200
Implement a metadata mechanism for the dual code+GraphQL API (#64800)
diff --git a/plugins/woocommerce/changelog/64730-add-schema-metadata-to-dual-api b/plugins/woocommerce/changelog/64730-add-schema-metadata-to-dual-api
new file mode 100644
index 00000000000..210556538e6
--- /dev/null
+++ b/plugins/woocommerce/changelog/64730-add-schema-metadata-to-dual-api
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a metadata mechanism to the dual code+GraphQL API
diff --git a/plugins/woocommerce/src/Api/Attributes/Experimental.php b/plugins/woocommerce/src/Api/Attributes/Experimental.php
new file mode 100644
index 00000000000..8e4f6af87be
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Experimental.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a code-API element as experimental: present in the schema, but not
+ * stable enough to be relied on in production.
+ *
+ * Discoverable through the `_apiMetadata` GraphQL field as an entry with
+ * `name = "experimental"` and `value = true`. The marking is informational:
+ * it does not gate access in any way.
+ *
+ * When a class, property or enum case has this attribute, the generated
+ * GraphQL `description` is prefixed with `[Experimental] `, and
+ * when the element has no `#[Description]` at all, a default body
+ * (`[Experimental] Not to be used in production environments.`) is emitted
+ * so the marker still reaches stock introspection.
+ *
+ * `#[Experimental]` on a class marks only that class: its fields and enum
+ * cases are not implicitly marked too. A tool that wants to treat the
+ * contents of an experimental type as experimental by association must
+ * apply that rule itself when it reads the metadata.
+ *
+ * The attribute targets classes, properties, and enum cases; see
+ * {@see Metadata} for the reasoning behind excluding methods.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS_CONSTANT )]
+class Experimental extends Metadata {
+ /**
+ * Construct an `experimental` metadata entry with value `true`.
+ */
+ public function __construct() {
+ parent::__construct( 'experimental', true );
+ }
+
+ /**
+ * Prepend `[Experimental] ` to the description, supplying a default body
+ * when the element has no `#[Description]` of its own. See
+ * {@see Metadata::transform_description()} for the contract.
+ *
+ * @param string $description Incoming description (empty when no `#[Description]`).
+ */
+ public function transform_description( string $description ): string {
+ if ( '' === $description ) {
+ $description = 'Not to be used in production environments.';
+ }
+ return '[Experimental] ' . $description;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Internal.php b/plugins/woocommerce/src/Api/Attributes/Internal.php
new file mode 100644
index 00000000000..74cf0e3c2aa
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Internal.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a code-API element as for WooCommerce internal use.
+ *
+ * Discoverable through the `_apiMetadata` GraphQL field as an entry with
+ * `name = "internal"` and `value = true`. The marking is informational —
+ * authorization remains the job of {@see PublicAccess}, {@see RequiredCapability},
+ * and any plugin-supplied authorization attributes.
+ *
+ * When a class, property or enum case has this attribute, the generated
+ * GraphQL `description` is prefixed with `[Internal] `, and
+ * when the element has no `#[Description]` at all, a default body
+ * (`[Internal] For WooCommerce core internal usage only.`) is emitted so the
+ * marker still reaches stock introspection.
+ *
+ * `#[Internal]` on a class marks only that class — its fields and enum cases
+ * are not implicitly marked too. A tool that wants to treat the contents of
+ * an internal type as internal by association must apply that rule itself
+ * when it reads the metadata.
+ *
+ * The attribute targets classes, properties, and enum cases; see
+ * {@see Metadata} for the reasoning behind excluding methods.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS_CONSTANT )]
+class Internal extends Metadata {
+ /**
+ * Construct an `internal` metadata entry with value `true`.
+ */
+ public function __construct() {
+ parent::__construct( 'internal', true );
+ }
+
+ /**
+ * Prepend `[Internal] ` to the description, supplying a default body when
+ * the element has no `#[Description]` of its own. See
+ * {@see Metadata::transform_description()} for the contract.
+ *
+ * @param string $description Incoming description (empty when no `#[Description]`).
+ */
+ public function transform_description( string $description ): string {
+ if ( '' === $description ) {
+ $description = 'For WooCommerce core internal usage only.';
+ }
+ return '[Internal] ' . $description;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Metadata.php b/plugins/woocommerce/src/Api/Attributes/Metadata.php
new file mode 100644
index 00000000000..46595fe5e19
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Metadata.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Attaches a name/value metadata entry to a code-API element.
+ * (class, class property, method parameter, method parameter, or enum case).
+ *
+ * Metadata entries are harvested by ApiBuilder and emitted into the generated
+ * schema, where they can be queried at runtime through the top-level
+ * `_apiMetadata` GraphQL field. The mechanism is intentionally open: subclass
+ * this attribute to ship a category of metadata (e.g. {@see Internal} for
+ * marking elements as for WooCommerce internal use). Tooling discovers metadata
+ * by name; the value is scalar-only so it can flow through GraphQL without
+ * additional encoding.
+ *
+ * Two metadata entries with the same name on the same element produce a
+ * build-time error, see ApiBuilder for the duplicate-name detection. Multiple
+ * distinct names on one element are allowed.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT | Attribute::IS_REPEATABLE )]
+class Metadata {
+ /**
+ * Constructor.
+ *
+ * @param string $name Identifier for this entry. Must be unique per element across all Metadata subclasses applied to it.
+ * @param bool|int|float|string|null $value Scalar payload exposed to clients via `_apiMetadata`.
+ */
+ public function __construct(
+ private string $name,
+ private bool|int|float|string|null $value,
+ ) {
+ }
+
+ /**
+ * The entry's name (e.g. `internal`, `beta`, `owner`).
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+
+ /**
+ * The entry's scalar value.
+ */
+ public function get_value(): bool|int|float|string|null {
+ return $this->value;
+ }
+
+ /**
+ * Transform the GraphQL `description` of the element this attribute is
+ * applied to.
+ *
+ * The base implementation is a no-op; the general `#[Metadata]` mechanism
+ * does not modify descriptions. Subclasses opt into the description-mirror
+ * convention by overriding this method — typically to prefix the input
+ * with a marker (`[Internal] `, `[Experimental] `, …) and supply a default
+ * body when the element has no `#[Description]` of its own.
+ *
+ * Conventions for overrides:
+ * - An empty `$description` means the element has no `#[Description]`.
+ * If the subclass wants the marker to still reach stock introspection,
+ * it should supply a sensible default text and prefix it as usual.
+ * - A non-empty `$description` may be either the developer's own text or
+ * the output of a previous attribute's transform; the subclass should
+ * not try to distinguish. Wrap-only (prefix the input, don't replace).
+ *
+ * When more than one transforming attribute is applied to the same
+ * element, ApiBuilder calls `transform_description()` once per attribute
+ * in PHP reflection (source) order, threading each return value into the
+ * next call. Because each subclass prefixes the input, the last attribute
+ * in source ends up as the outermost prefix in the final string. Order
+ * the attributes accordingly when the reading order matters.
+ *
+ * @internal Called by ApiBuilder; not part of any caller-visible contract.
+ *
+ * @param string $description The current description text (`''` when the element has no `#[Description]`, or the previous transform's output when chained).
+ */
+ public function transform_description( string $description ): string {
+ return $description;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Utils/SchemaHandle.php b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
new file mode 100644
index 00000000000..1a2b7f5e68b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
@@ -0,0 +1,232 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils;
+
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\EnumType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\HasFieldsType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InputObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
+
+/**
+ * Opaque handle to a dual-API GraphQL schema, exposing the runtime inspection
+ * operations the dual-API surface supports.
+ *
+ * The handle wraps the live engine schema but does not expose it. Clients
+ * therefore depend only on the methods this class declares — never on the
+ * underlying engine type — which keeps a future engine swap as a non-public
+ * API change.
+ *
+ * Construction is reserved for the dual-API infrastructure. Obtain a handle
+ * via your dual-API `GraphQLController`'s `get_schema()` method:
+ *
+ * $schema = wc_get_container()
+ * ->get( \Automattic\WooCommerce\Internal\Api\GraphQLController::class )
+ * ->get_schema();
+ *
+ * WooCommerce plugins implementing their own dual API reach a handle through
+ * their own concrete controller the same way.
+ *
+ * The current public surface is the metadata channel: {@see self::get_all_metadata()}
+ * returns every metadata row in the schema, {@see self::find_metadata()} applies
+ * filter-narrows semantics over the same set.
+ *
+ * @phpstan-type MetadataRow array{
+ * type: string,
+ * field: ?string,
+ * argument: ?string,
+ * enumValue: ?string,
+ * entries: array<string, bool|int|float|string|null>
+ * }
+ */
+final class SchemaHandle {
+
+ /**
+ * The wrapped engine schema. Typed as `object` (rather than the engine's
+ * `Schema` class) so the class signature carries no engine-specific
+ * symbol; the inspection methods cast to engine APIs internally.
+ *
+ * @var object
+ */
+ private object $engine_schema;
+
+ /**
+ * Wrap an engine schema in a handle.
+ *
+ * @internal Reserved for dual-API infrastructure (the controller's `get_schema()` accessor and similarly placed code). Plugins obtain a handle through their own controller, not by instantiating directly.
+ *
+ * @param object $engine_schema Engine-specific schema instance the handle wraps.
+ */
+ public function __construct( object $engine_schema ) {
+ $this->engine_schema = $engine_schema;
+ }
+
+ /**
+ * Return every metadata row in the schema (introspection types excluded).
+ *
+ * Each row describes one *target* (a type, a field, an argument, or an
+ * enum value) and carries the name=>value entries declared on it. The
+ * same row shape is used for every target kind; the three nullable
+ * position fields (`field`, `argument`, `enumValue`) discriminate.
+ *
+ * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+ */
+ public function get_all_metadata(): array {
+ $rows = array();
+ $schema = $this->engine_schema;
+
+ foreach ( $schema->getTypeMap() as $type_name => $type ) {
+ if ( self::is_introspection_name( $type_name ) ) {
+ continue;
+ }
+
+ $type_metadata = self::read_type_metadata( $type );
+ if ( ! empty( $type_metadata ) ) {
+ $rows[] = self::make_row( $type_name, null, null, null, $type_metadata );
+ }
+
+ if ( $type instanceof HasFieldsType ) {
+ foreach ( $type->getFields() as $field_name => $field ) {
+ $field_metadata = self::read_element_metadata( $field );
+ if ( ! empty( $field_metadata ) ) {
+ $rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata );
+ }
+
+ foreach ( $field->args as $arg ) {
+ $arg_metadata = self::read_element_metadata( $arg );
+ if ( ! empty( $arg_metadata ) ) {
+ $rows[] = self::make_row( $type_name, $field_name, $arg->name, null, $arg_metadata );
+ }
+ }
+ }
+ continue;
+ }
+
+ if ( $type instanceof InputObjectType ) {
+ foreach ( $type->getFields() as $field_name => $field ) {
+ $field_metadata = self::read_element_metadata( $field );
+ if ( ! empty( $field_metadata ) ) {
+ $rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata );
+ }
+ }
+ continue;
+ }
+
+ if ( $type instanceof EnumType ) {
+ foreach ( $type->getValues() as $value ) {
+ $value_metadata = self::read_element_metadata( $value );
+ if ( ! empty( $value_metadata ) ) {
+ $rows[] = self::make_row( $type_name, null, null, $value->name, $value_metadata );
+ }
+ }
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Filter-narrows view over {@see self::get_all_metadata()}.
+ *
+ * Each filter argument independently restricts the result set; supplying
+ * multiple composes as AND. When `$name` is supplied, the surviving rows
+ * have their `entries` trimmed to the single matching entry; so a caller
+ * asking "which elements are marked X" gets focused rows back, not the
+ * full multi-entry shape.
+ *
+ * @param ?string $name Optional metadata name to match. When set, only rows containing this entry survive and their `entries` are trimmed to it.
+ * @param ?string $type Optional GraphQL type name to match.
+ * @param ?string $field Optional GraphQL field name to match.
+ *
+ * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+ */
+ public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null ): array {
+ $rows = $this->get_all_metadata();
+
+ $result = array();
+ foreach ( $rows as $row ) {
+ if ( null !== $type && $row['type'] !== $type ) {
+ continue;
+ }
+ if ( null !== $field && $row['field'] !== $field ) {
+ continue;
+ }
+ if ( null !== $name ) {
+ if ( ! array_key_exists( $name, $row['entries'] ) ) {
+ continue;
+ }
+ $row['entries'] = array( $name => $row['entries'][ $name ] );
+ }
+ $result[] = $row;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Read type-level metadata from a wrapped engine type.
+ *
+ * The wrapper subclasses in `Internal/Api/Schema/` expose `get_metadata()`;
+ * non-wrapper types (e.g. the built-in scalars, the introspection types we
+ * already filtered out) don't carry metadata and contribute an empty array.
+ *
+ * @param Type $type The GraphQL type to inspect.
+ * @return array<string, bool|int|float|string|null>
+ */
+ private static function read_type_metadata( Type $type ): array {
+ if ( method_exists( $type, 'get_metadata' ) ) {
+ $metadata = $type->get_metadata();
+ return is_array( $metadata ) ? $metadata : array();
+ }
+ return array();
+ }
+
+ /**
+ * Read field-/arg-/enum-value-level metadata from the original config array.
+ *
+ * FieldDefinition, Argument, InputObjectField and EnumValueDefinition all
+ * preserve their construction config in a public `$config` property, so
+ * the `metadata` key emitted by ApiBuilder is reachable here without any
+ * wrapper-side plumbing.
+ *
+ * @param object $element FieldDefinition | Argument | InputObjectField | EnumValueDefinition.
+ * @return array<string, bool|int|float|string|null>
+ */
+ private static function read_element_metadata( object $element ): array {
+ if ( ! property_exists( $element, 'config' ) ) {
+ return array();
+ }
+ $metadata = $element->config['metadata'] ?? array();
+ return is_array( $metadata ) ? $metadata : array();
+ }
+
+ /**
+ * Build a metadata row in the standard shape.
+ *
+ * @param string $type GraphQL type name.
+ * @param ?string $field Field name when the row describes a field; null otherwise.
+ * @param ?string $argument Argument name when the row describes a field argument; null otherwise.
+ * @param ?string $enum_value Enum value name when the row describes an enum value; null otherwise.
+ * @param array<string, bool|int|float|string|null> $entries Name=>value entries to attach to the row.
+ * @return array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}
+ */
+ private static function make_row( string $type, ?string $field, ?string $argument, ?string $enum_value, array $entries ): array {
+ return array(
+ 'type' => $type,
+ 'field' => $field,
+ 'argument' => $argument,
+ 'enumValue' => $enum_value,
+ 'entries' => $entries,
+ );
+ }
+
+ /**
+ * Whether a type name belongs to GraphQL's introspection system (and so should be skipped).
+ *
+ * @param string $name Type name.
+ */
+ private static function is_introspection_name( string $name ): bool {
+ return str_starts_with( $name, '__' );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
index 92f8cecd7d6..e1f15a579bc 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListProduct
use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\GetProduct;
use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\GetCoupon;
use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListCoupons;
+use Automattic\WooCommerce\Internal\Api\MetadataController;
use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
class RootQueryType {
@@ -20,10 +21,11 @@ class RootQueryType {
array(
'name' => 'Query',
'fields' => fn() => array(
- 'products' => ListProducts::get_field_definition(),
- 'product' => GetProduct::get_field_definition(),
- 'coupon' => GetCoupon::get_field_definition(),
- 'coupons' => ListCoupons::get_field_definition(),
+ 'products' => ListProducts::get_field_definition(),
+ 'product' => GetProduct::get_field_definition(),
+ 'coupon' => GetCoupon::get_field_definition(),
+ 'coupons' => ListCoupons::get_field_definition(),
+ MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
),
)
);
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index b612678aa4d..b1c719ab326 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-06T16:48:36+00:00
\ No newline at end of file
+2026-05-12T08:46:18+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
index 0a2ea5661e9..0a3ac414825 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Api\Pagination\Connection;
use Automattic\WooCommerce\Api\Attributes\Deprecated;
use Automattic\WooCommerce\Api\Attributes\Description;
use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
use Automattic\WooCommerce\Api\Attributes\Name;
use Automattic\WooCommerce\Api\Attributes\Parameter;
use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
@@ -155,6 +156,17 @@ class ApiBuilder {
/** @var string[] Errors collected during validation */
private array $errors = array();
+ /**
+ * Cache of harvested metadata, keyed by the context label that
+ * {@see self::harvest_metadata()} is called with. Populated by the
+ * pre-generate {@see self::validate_metadata()} pass and re-used by
+ * the generate phase, so duplicate-name errors surface before the
+ * autogenerated tree is wiped and each conflict is only recorded once.
+ *
+ * @var array<string, array<string, bool|int|float|string|null>>
+ */
+ private array $metadata_cache = array();
+
/** @var string[] Warnings collected during build */
private array $warnings = array();
@@ -229,7 +241,9 @@ class ApiBuilder {
$this->detect_status_resolver();
$this->discover_authorization_attributes();
$this->discover();
+ $this->check_attribute_resolutions();
$this->validate();
+ $this->validate_metadata();
if ( ! empty( $this->errors ) ) {
fwrite( STDERR, "Build failed with errors:\n" );
@@ -1247,6 +1261,7 @@ class ApiBuilder {
'case_name' => $case->getName(),
'description' => $this->get_description( $case ),
'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
+ 'metadata' => $this->harvest_metadata( $case, "{$ref->getShortName()}::{$case->getName()}" ),
);
}
@@ -1260,6 +1275,7 @@ class ApiBuilder {
'enum_fqcn' => $fqcn,
'enum_alias' => $enum_alias,
'values' => $values,
+ 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
)
);
@@ -1284,6 +1300,7 @@ class ApiBuilder {
'description' => $description,
'scalar_fqcn' => $fqcn,
'scalar_alias' => $scalar_alias,
+ 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
)
);
@@ -1333,6 +1350,7 @@ class ApiBuilder {
'use_statements' => array_unique( $use_stmts ),
'fields' => $fields,
'type_map' => $type_map,
+ 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
)
);
@@ -1383,6 +1401,7 @@ class ApiBuilder {
'use_statements' => array_unique( $use_stmts ),
'interfaces' => $interfaces,
'fields' => $fields,
+ 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
)
);
@@ -1426,6 +1445,7 @@ class ApiBuilder {
'description' => $description,
'use_statements' => array_unique( $use_stmts ),
'fields' => $fields,
+ 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
)
);
@@ -1525,6 +1545,7 @@ class ApiBuilder {
'description' => $param_description,
'has_default' => $param->isDefaultValueAvailable(),
'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
+ 'metadata' => $this->harvest_metadata( $param, "{$ref->getShortName()}::execute() parameter \${$param_name}" ),
);
$args[] = $arg_entry;
@@ -1634,6 +1655,7 @@ class ApiBuilder {
'attribute_expr' => $attribute_expr,
'compute_preauthorized_param_type' => $compute_preauthorized_param_type,
'scalar_return' => $scalar_return,
+ 'metadata' => $this->harvest_metadata( $ref, $ref->getShortName() ),
)
);
@@ -1905,6 +1927,7 @@ class ApiBuilder {
// Description from attribute.
$desc_attrs = $prop->getAttributes( Description::class );
$description = ! empty( $desc_attrs ) ? $desc_attrs[0]->newInstance()->description : '';
+ $description = $this->apply_description_transforms( $description, $prop );
// Default value.
$has_default = $prop->hasDefaultValue();
@@ -1933,6 +1956,14 @@ class ApiBuilder {
'description' => $description,
'has_default' => $has_default,
'default' => $default,
+ // Unrolled args inherit metadata from the source property: the
+ // unroll is a renaming of input fields into top-level args, so
+ // any `#[Metadata]` on the property travels with the rename.
+ // Use the declaring class for the label so the cache populated
+ // by `validate_metadata()`'s property walk (which keys by the
+ // declaring class) hits the same entry and conflicts are only
+ // recorded once.
+ 'metadata' => $this->harvest_metadata( $prop, "{$prop->getDeclaringClass()->getShortName()}::\${$prop_name}" ),
);
// Value expression for the constructor call.
@@ -2227,19 +2258,220 @@ class ApiBuilder {
}
private function get_description( \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase $ref ): string {
- $attrs = $ref->getAttributes( Description::class );
- return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+ $attrs = $ref->getAttributes( Description::class );
+ $description = ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+ return $this->apply_description_transforms( $description, $ref );
}
private function get_param_description( \ReflectionParameter $param ): string {
- $attrs = $param->getAttributes( Description::class );
- return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+ $attrs = $param->getAttributes( Description::class );
+ $description = ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+ return $this->apply_description_transforms( $description, $param );
+ }
+
+ /**
+ * Thread the description through {@see Metadata::transform_description()}
+ * on every `Metadata`-derived attribute applied to the element.
+ *
+ * The base `Metadata` implementation is a no-op; subclasses opt into the
+ * description-mirror convention by overriding (`#[Internal]` prefixes
+ * `[Internal] ` and supplies a default body, `#[Experimental]` does the
+ * analogous thing, etc.). Calls happen in PHP reflection (source) order,
+ * threading each return value into the next, so the last attribute in
+ * source ends up as the outermost prefix.
+ *
+ * The returned text is consumed verbatim by the templates and wrapped in
+ * `__( ..., 'woocommerce' )`, so any default description supplied by a
+ * subclass flows through the usual translation pipeline like any other
+ * description.
+ *
+ * @param string $description Original description text (empty when the element has no `#[Description]`).
+ * @param \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter $source Reflector whose `Metadata`-derived attributes drive the transforms.
+ */
+ private function apply_description_transforms( string $description, $source ): string {
+ foreach ( $source->getAttributes( Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
+ $description = $attribute->newInstance()->transform_description( $description );
+ }
+ return $description;
+ }
+
+ /**
+ * Walk every discovered class and emit a build-time warning for any
+ * attribute reference whose class doesn't autoload.
+ *
+ * Symptom this catches: a developer writes `#[Metadata( … )]` (or any
+ * other attribute) without importing the class, so PHP resolves the
+ * reference relative to the current namespace and lands on a non-existent
+ * class. ApiBuilder filters attributes by class via reflection and
+ * silently ignores unresolved ones, so the attribute disappears from the
+ * generated tree without any visible error — confusing to debug from the
+ * runtime side. The warning surfaces the typo / missing import at build
+ * time instead.
+ *
+ * The check is intentionally untyped: it warns about every attribute it
+ * can't resolve, regardless of which `Metadata`/`Description`/`Internal`/etc.
+ * the developer intended. The warning text quotes the unresolved FQCN so
+ * the developer can see exactly what PHP tried to load.
+ */
+ private function check_attribute_resolutions(): void {
+ foreach ( $this->classes as $info ) {
+ $ref = $info['class'];
+ $this->check_attributes_on( $ref, $ref->getShortName() );
+
+ foreach ( $ref->getProperties() as $prop ) {
+ $this->check_attributes_on( $prop, $ref->getShortName() . '::$' . $prop->getName() );
+ }
+
+ foreach ( $ref->getMethods() as $method ) {
+ if ( $method->getDeclaringClass()->getName() !== $ref->getName() ) {
+ continue;
+ }
+ $this->check_attributes_on( $method, $ref->getShortName() . '::' . $method->getName() . '()' );
+
+ foreach ( $method->getParameters() as $param ) {
+ $this->check_attributes_on(
+ $param,
+ $ref->getShortName() . '::' . $method->getName() . '($' . $param->getName() . ')'
+ );
+ }
+ }
+
+ if ( $ref instanceof \ReflectionEnum ) {
+ foreach ( $ref->getCases() as $case ) {
+ $this->check_attributes_on( $case, $ref->getShortName() . '::' . $case->getName() );
+ }
+ }
+ }
+ }
+
+ /**
+ * Check every attribute applied to a single reflector and accumulate a
+ * warning per unresolvable class.
+ *
+ * @param \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter $source Reflector to scan.
+ * @param string $context_label Human-readable label used in the warning.
+ */
+ private function check_attributes_on( $source, string $context_label ): void {
+ foreach ( $source->getAttributes() as $attribute ) {
+ $name = $attribute->getName();
+ if ( ! class_exists( $name ) ) {
+ $this->warnings[] = "{$context_label}: attribute `{$name}` could not be resolved (likely a missing `use` statement). The attribute was silently ignored during generation.";
+ }
+ }
+ }
+
+ /**
+ * Collect {@see Metadata}-derived attributes from a reflector into a
+ * `name => value` map.
+ *
+ * Two attributes producing the same `name` on the same element are a
+ * generation-time error: surprising metadata in production is worse than a
+ * loud build failure. The check spans subclasses too (e.g. a future
+ * `#[Beta]` that also yielded `name = 'internal'` would conflict with a
+ * sibling `#[Internal]`), which is why we match by `instanceof Metadata`
+ * rather than by attribute class.
+ *
+ * @param \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter $source Reflector to read attributes from.
+ * @param string $context_label Human-readable label for the source, used in error messages (e.g. `"Coupon::$lock_state"`).
+ *
+ * @return array<string, bool|int|float|string|null>
+ */
+ private function harvest_metadata( $source, string $context_label ): array {
+ if ( array_key_exists( $context_label, $this->metadata_cache ) ) {
+ return $this->metadata_cache[ $context_label ];
+ }
+
+ $entries = array();
+ foreach ( $source->getAttributes( Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
+ $instance = $attribute->newInstance();
+ $name = $instance->get_name();
+ if ( array_key_exists( $name, $entries ) ) {
+ $this->errors[] = "{$context_label}: duplicate metadata name '{$name}'.";
+ continue;
+ }
+ $entries[ $name ] = $instance->get_value();
+ }
+ $this->metadata_cache[ $context_label ] = $entries;
+ return $entries;
+ }
+
+ /**
+ * Pre-generate pass that walks every reflector the generate phase will
+ * later harvest metadata from, populating {@see self::$metadata_cache}
+ * and recording duplicate-name conflicts in {@see self::$errors}.
+ *
+ * Without this pass, conflicts only surface inside `generate_*` — which
+ * runs after {@see self::wipe_autogenerated()} — so a bad attribute set
+ * would silently rewrite the autogenerated tree (keeping the first-seen
+ * value) and the error would be lost because nothing reads `$errors`
+ * after generate(). Running the walk up front lets the existing
+ * build-level error check catch duplicates before any file is touched.
+ */
+ private function validate_metadata(): void {
+ foreach ( $this->classes as $info ) {
+ if ( $info['ignored'] || 'pagination' === $info['kind'] ) {
+ continue;
+ }
+
+ $ref = $info['class'];
+ $kind = $info['kind'];
+
+ // Type-level metadata on every kind that emits a webonyx type config.
+ if ( in_array( $kind, array( 'type', 'input_type', 'enum', 'interface', 'scalar', 'query', 'mutation' ), true ) ) {
+ $this->harvest_metadata( $ref, $ref->getShortName() );
+ }
+
+ // Per-property metadata on output/input/interface types. The label must
+ // match `build_field_definition()`'s, which uses the property's
+ // declaring class — that handles inherited public properties cleanly.
+ if ( in_array( $kind, array( 'type', 'input_type', 'interface' ), true ) ) {
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+ $label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+ $this->harvest_metadata( $prop, $label );
+ }
+ }
+
+ // Enum cases.
+ if ( 'enum' === $kind && $ref instanceof \ReflectionEnum ) {
+ foreach ( $ref->getCases() as $case ) {
+ $this->harvest_metadata( $case, "{$ref->getShortName()}::{$case->getName()}" );
+ }
+ }
+
+ // Query/mutation execute() parameters. Skip infrastructure parameters
+ // (`_principal`, `_query_info`, `_preauthorized`) — they have no
+ // matching schema element and never carry `#[Metadata]`. Unrolled
+ // parameters reach harvest_metadata through their backing input type's
+ // properties, which the property walk above already covers.
+ if ( in_array( $kind, array( 'query', 'mutation' ), true ) && $ref->hasMethod( 'execute' ) ) {
+ foreach ( $ref->getMethod( 'execute' )->getParameters() as $param ) {
+ $param_name = $param->getName();
+ if ( '' !== $param_name && '_' === $param_name[0] ) {
+ continue;
+ }
+ $this->harvest_metadata( $param, "{$ref->getShortName()}::execute() parameter \${$param_name}" );
+ }
+ }
+ }
}
private function get_class_info( string $class_name ): ?array {
return $this->classes[ $class_name ] ?? null;
}
+ /**
+ * Build the field-definition array consumed by templates.
+ *
+ * Per-field metadata is harvested from the property itself. Per-argument
+ * metadata for `#[Parameter]`-declared arguments on output fields is *not*
+ * supported in the MVP because the `Parameter` attribute carries the arg
+ * shape inline; there is no separate target to decorate with `#[Metadata]`.
+ * Root-operation arguments (declared as `execute()` parameters) do get
+ * per-argument metadata — that path runs through {@see self::generate_resolver()}.
+ */
private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts ): ?array {
$type = $prop->getType();
$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
@@ -2256,6 +2488,7 @@ class ApiBuilder {
if ( ! empty( $desc_attrs ) ) {
$description = $desc_attrs[0]->newInstance()->description;
}
+ $description = $this->apply_description_transforms( $description, $prop );
$deprecation = $prop->getAttributes( Deprecated::class );
@@ -2279,6 +2512,10 @@ class ApiBuilder {
'name' => $param_inst->name,
'type_expr' => $arg_type_expr,
'description' => $param_inst->description,
+ // `#[Parameter]`-declared args carry their shape inline and
+ // have no separate reflector to read `#[Metadata]` from, so
+ // the MVP exposes them without metadata.
+ 'metadata' => array(),
);
if ( $param_inst->has_default ) {
$arg_entry['default'] = $param_inst->default;
@@ -2307,6 +2544,8 @@ class ApiBuilder {
$has_pagination = $is_connection && ! empty( $args );
$paginated_connection = $context === 'output' && $has_pagination;
+ $field_context_label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+
return array(
'name' => $prop->getName(),
'type_expr' => $type_expr,
@@ -2314,6 +2553,7 @@ class ApiBuilder {
'args' => $args,
'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
'paginated_connection' => $paginated_connection,
+ 'metadata' => $this->harvest_metadata( $prop, $field_context_label ),
);
}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
index ef99bdb95cf..3fe3d00df54 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
@@ -8,7 +8,8 @@
* @var string $description
* @var string $enum_fqcn
* @var string $enum_alias
- * @var array $values - each: ['graphql_name', 'case_name', 'description', 'deprecation_reason' => ?string]
+ * @var array $values - each: ['graphql_name', 'case_name', 'description', 'deprecation_reason' => ?string, 'metadata' => array]
+ * @var array $metadata - type-level metadata, name => scalar value.
*/
$escaped_description = addslashes( $description );
@@ -34,6 +35,13 @@ class <?php echo $class_name; ?> {
'name' => '<?php echo $graphql_name; ?>',
<?php if ( $description !== '' ) : ?>
'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+ 'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'values' => array(
<?php foreach ( $values as $val ) : ?>
@@ -44,6 +52,13 @@ class <?php echo $class_name; ?> {
<?php endif; ?>
<?php if ( ! empty( $val['deprecation_reason'] ) ) : ?>
'deprecationReason' => '<?php echo addslashes( $val['deprecation_reason'] ); ?>',
+<?php endif; ?>
+ <?php if ( ! empty( $val['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $val['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
),
<?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
index 6ee83867b05..123c37f8c43 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
@@ -7,7 +7,8 @@
* @var string $graphql_name
* @var string $description
* @var array $use_statements
- * @var array $fields - each: ['name', 'type_expr', 'description']
+ * @var array $fields - each: ['name', 'type_expr', 'description', 'metadata' => array]
+ * @var array $metadata - type-level metadata, name => scalar value.
*/
$escaped_description = addslashes( $description );
@@ -61,6 +62,13 @@ class <?php echo $class_name; ?> {
'name' => '<?php echo $graphql_name; ?>',
<?php if ( $description !== '' ) : ?>
'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+ 'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'fields' => fn() => array(
<?php foreach ( $fields as $field ) : ?>
@@ -68,6 +76,13 @@ class <?php echo $class_name; ?> {
'type' => <?php echo $field['type_expr']; ?>,
<?php if ( ! empty( $field['description'] ) ) : ?>
'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $field['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
),
<?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
index ae4987c820f..83cdd7fcf4e 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
@@ -7,8 +7,9 @@
* @var string $graphql_name
* @var string $description
* @var array $use_statements
- * @var array $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string]
+ * @var array $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'metadata' => array]
* @var array $type_map - each: ['fqcn' => string, 'alias' => string] mapping PHP FQCN to generated ObjectType alias
+ * @var array $metadata - type-level metadata, name => scalar value.
*/
$escaped_description = addslashes( $description );
@@ -62,6 +63,13 @@ class <?php echo $class_name; ?> {
'name' => '<?php echo $graphql_name; ?>',
<?php if ( $description !== '' ) : ?>
'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+ 'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'fields' => fn() => array(
<?php foreach ( $fields as $field ) : ?>
@@ -69,6 +77,13 @@ class <?php echo $class_name; ?> {
'type' => <?php echo $field['type_expr']; ?>,
<?php if ( ! empty( $field['description'] ) ) : ?>
'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $field['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
<?php if ( ! empty( $field['args'] ) ) : ?>
'args' => array(
@@ -80,6 +95,13 @@ class <?php echo $class_name; ?> {
<?php endif; ?>
<?php if ( ! empty( $arg['description'] ) ) : ?>
'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $arg['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $arg['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
),
<?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
index 89692d0e473..eb2348a59b0 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
@@ -8,7 +8,8 @@
* @var string $description
* @var array $use_statements
* @var array $interfaces - each: ['alias' => string]
- * @var array $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool]
+ * @var array $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool, 'metadata' => array]
+ * @var array $metadata - type-level metadata, name => scalar value.
*/
$escaped_description = addslashes( $description );
@@ -78,6 +79,13 @@ class <?php echo $class_name; ?> {
<?php if ( $description !== '' ) : ?>
'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+ 'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
+<?php endif; ?>
<?php if ( ! empty( $interfaces ) ) : ?>
'interfaces' => fn() => array(
<?php foreach ( $interfaces as $iface ) : ?>
@@ -91,6 +99,13 @@ class <?php echo $class_name; ?> {
'type' => <?php echo $field['type_expr']; ?>,
<?php if ( ! empty( $field['description'] ) ) : ?>
'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $field['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
<?php if ( ! empty( $field['args'] ) ) : ?>
'args' => array(
@@ -102,6 +117,13 @@ class <?php echo $class_name; ?> {
<?php endif; ?>
<?php if ( ! empty( $arg['description'] ) ) : ?>
'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $arg['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $arg['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
),
<?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
index 4dffd3f2f22..e42972dcac0 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
@@ -10,7 +10,8 @@
* @var string $command_alias
* @var string $return_type_expr
* @var array $use_statements
- * @var array $args - each: ['name', 'type_expr', 'description', 'has_default', 'default']
+ * @var array $args - each: ['name', 'type_expr', 'description', 'has_default', 'default', 'metadata' => array]
+ * @var array $metadata - root-field-level metadata, name => scalar value.
* @var bool $has_connection_of
* @var string $connection_type_alias
* @var bool $standalone_attribute_check - true when authorize() is absent and the attribute_expr is the sole authorization gate
@@ -91,6 +92,13 @@ class <?php echo $class_name; ?> {
<?php endif; ?>
<?php if ( $description !== '' ) : ?>
'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+ 'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'args' => array(
<?php foreach ( $args as $arg ) : ?>
@@ -101,6 +109,13 @@ class <?php echo $class_name; ?> {
<?php endif; ?>
<?php if ( $arg['has_default'] ) : ?>
'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+ <?php if ( ! empty( $arg['metadata'] ) ) : ?>
+ 'metadata' => array(
+ <?php foreach ( $arg['metadata'] as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
),
<?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
index fea9414ed2c..55c8c3531ff 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
@@ -2,6 +2,11 @@
/**
* Template for generating the RootQueryType class.
*
+ * Besides the autogenerated query fields, the root `Query` type also gets the
+ * hand-written `_apiMetadata` field contributed by {@see \Automattic\WooCommerce\Internal\Api\MetadataController}.
+ * That class lives in the shared infrastructure namespace, so plugins reusing
+ * this template inherit the metadata-discovery field for free.
+ *
* @var string $namespace
* @var array $queries - each: ['class_name', 'fqcn', 'graphql_name']
*/
@@ -17,6 +22,7 @@ namespace <?php echo $namespace; ?>;
<?php foreach ( $queries as $query ) : ?>
use <?php echo $query['fqcn']; ?>;
<?php endforeach; ?>
+use Automattic\WooCommerce\Internal\Api\MetadataController;
use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
class RootQueryType {
@@ -31,6 +37,7 @@ class RootQueryType {
<?php foreach ( $queries as $query ) : ?>
'<?php echo $query['graphql_name']; ?>' => <?php echo $query['class_name']; ?>::get_field_definition(),
<?php endforeach; ?>
+ MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
),
)
);
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
index 58a4bfdac90..42b6011405f 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
@@ -8,6 +8,7 @@
* @var string $description
* @var string $scalar_fqcn
* @var string $scalar_alias
+ * @var array $metadata - type-level metadata, name => scalar value.
*/
$escaped_description = addslashes( $description );
@@ -33,6 +34,13 @@ class <?php echo $class_name; ?> {
'name' => '<?php echo $graphql_name; ?>',
<?php if ( $description !== '' ) : ?>
'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+ 'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+ <?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+ ),
<?php endif; ?>
'serialize' => fn( $value ) => <?php echo $scalar_alias; ?>::serialize( $value ),
'parseValue' => function ( $value ) {
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index 3e108608a0b..b814a3fb554 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Api;
use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
use Automattic\WooCommerce\Vendor\GraphQL\GraphQL;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
@@ -63,6 +64,13 @@ abstract class GraphQLController {
*/
private ?Schema $schema = null;
+ /**
+ * Cached public-facing schema handle wrapping {@see self::$schema}.
+ *
+ * @var ?SchemaHandle
+ */
+ private ?SchemaHandle $schema_handle = null;
+
/**
* Query cache / APQ resolver.
*
@@ -392,7 +400,7 @@ abstract class GraphQLController {
}
// 5. Load schema.
- $schema = $this->get_schema();
+ $schema = $this->get_engine_schema();
// 6. Build validation rules.
// A single complexity-rule instance is kept so its computed score can
@@ -504,9 +512,28 @@ abstract class GraphQLController {
}
/**
- * Build and cache the GraphQL schema.
+ * Public handle to the live GraphQL schema for runtime inspection.
+ *
+ * Returns an opaque {@see SchemaHandle}; callers reach metadata (and any
+ * future schema-inspection operations) through methods on that object
+ * rather than touching the underlying engine type. The handle is cached
+ * and wraps the same engine schema this controller uses to serve real
+ * requests.
+ */
+ public function get_schema(): SchemaHandle {
+ if ( null === $this->schema_handle ) {
+ $this->schema_handle = new SchemaHandle( $this->get_engine_schema() );
+ }
+ return $this->schema_handle;
+ }
+
+ /**
+ * Build and cache the engine-typed GraphQL schema used internally to
+ * serve requests. Kept private to keep the engine type out of the
+ * controller's public surface; consumers should reach {@see SchemaHandle}
+ * through {@see self::get_schema()} instead.
*/
- private function get_schema(): Schema {
+ private function get_engine_schema(): Schema {
if ( null === $this->schema ) {
$this->schema = $this->build_schema();
}
diff --git a/plugins/woocommerce/src/Internal/Api/MetadataController.php b/plugins/woocommerce/src/Internal/Api/MetadataController.php
new file mode 100644
index 00000000000..73ca0b3c250
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/MetadataController.php
@@ -0,0 +1,258 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+use Automattic\WooCommerce\Internal\Api\Schema\CustomScalarType;
+use Automattic\WooCommerce\Internal\Api\Schema\Error;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\BooleanValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FloatValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\IntValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NullValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\StringValueNode;
+
+/**
+ * Hand-written controller that contributes the `_apiMetadata` root query field
+ * and the supporting `MetadataEntry`, `MetadataTarget`, and `MetadataValue`
+ * types to the generated schema.
+ *
+ * The autogenerated `RootQueryType` references this controller alongside the
+ * autogenerated query resolvers, so the field appears on the root `Query`
+ * type without any special wiring at the controller level. The resolver
+ * delegates to {@see MetadataUtils::find()} for the schema walk and filter
+ * application, then reshapes the rows so each entry is exposed as the
+ * `{ name, value }` pair that `MetadataEntry` expects.
+ *
+ * @internal Reserved for the GraphQL autogeneration infrastructure. The
+ * `_apiMetadata` field is the public-facing surface; this class is
+ * how it is wired in.
+ */
+class MetadataController {
+ /**
+ * Memoised `MetadataValue` scalar type.
+ *
+ * @var ?CustomScalarType
+ */
+ private static ?CustomScalarType $value_scalar = null;
+
+ /**
+ * Memoised `MetadataEntry` output type.
+ *
+ * @var ?ObjectType
+ */
+ private static ?ObjectType $entry_type = null;
+
+ /**
+ * Memoised `MetadataTarget` output type.
+ *
+ * @var ?ObjectType
+ */
+ private static ?ObjectType $target_type = null;
+
+ /**
+ * GraphQL field name used on the root `Query` type.
+ */
+ public const FIELD_NAME = '_apiMetadata';
+
+ /**
+ * Field definition for the root `_apiMetadata` query, in the shape the
+ * autogenerated `RootQueryType` expects (same as every autogenerated
+ * resolver's `get_field_definition()`).
+ *
+ * @return array<string, mixed>
+ */
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( self::get_target_type() ) ) ),
+ 'description' => __(
+ 'Lists metadata attached to elements of this schema. All filter arguments are optional; supplying multiple narrows the result. Use this to discover internal-use APIs, beta features, ownership, etc., or to ask "can I use this specific element?".',
+ 'woocommerce'
+ ),
+ 'args' => array(
+ 'name' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Match rows that carry a metadata entry with this name. Surviving rows have their entries trimmed to the matching one.', 'woocommerce' ),
+ ),
+ 'type' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Match rows whose target type equals this name.', 'woocommerce' ),
+ ),
+ 'field' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Match rows whose target field equals this name.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ /**
+ * Resolver for the `_apiMetadata` root field. Signature matches the
+ * webonyx resolver contract; `$root` and `$context` are unused here
+ * (root operations have no parent, and metadata is principal-independent).
+ *
+ * @param ?array $root Webonyx passes null for root resolvers.
+ * @param array $args GraphQL arguments (`name`, `type`, `field`).
+ * @param array $context Per-request context the controller builds.
+ * @param ResolveInfo $info Carries the schema instance to walk.
+ * @return list<array<string, mixed>>
+ */
+ public static function resolve( ?array $root, array $args, array $context, ResolveInfo $info ): array {
+ unset( $root, $context );
+
+ // Wrap the resolver's engine-typed schema into the same handle clients
+ // receive from `GraphQLController::get_schema()`, so the resolver and
+ // PHP-side callers share a single inspection surface.
+ $schema = new SchemaHandle( $info->schema );
+
+ $rows = $schema->find_metadata(
+ $args['name'] ?? null,
+ $args['type'] ?? null,
+ $args['field'] ?? null,
+ );
+
+ // SchemaHandle returns entries as an associative `name => value` map,
+ // which is the natural shape for filtering and PHP-side consumers. The
+ // GraphQL `MetadataEntry` type instead exposes each entry as a
+ // `{ name, value }` object so clients can `entries { name value }` over
+ // a list. Reshape here.
+ return array_map(
+ static function ( array $row ): array {
+ $row['entries'] = array_map(
+ static fn( string $entry_name, $entry_value ): array => array(
+ 'name' => $entry_name,
+ 'value' => $entry_value,
+ ),
+ array_keys( $row['entries'] ),
+ array_values( $row['entries'] ),
+ );
+ return $row;
+ },
+ $rows
+ );
+ }
+
+ /**
+ * The `MetadataTarget` output type, lazily built and cached.
+ */
+ private static function get_target_type(): ObjectType {
+ if ( null === self::$target_type ) {
+ self::$target_type = new ObjectType(
+ array(
+ 'name' => 'MetadataTarget',
+ 'description' => __(
+ 'One element of the schema with its attached metadata. Type-level rows have `field`, `argument` and `enumValue` set to null; field-level rows set `field` (and `argument` when the target is a field argument); enum-value rows set `enumValue`.',
+ 'woocommerce'
+ ),
+ 'fields' => fn() => array(
+ 'type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'Name of the GraphQL type this row describes.', 'woocommerce' ),
+ ),
+ 'field' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Field name when this row describes a field (or a field argument); null for type-level rows.', 'woocommerce' ),
+ ),
+ 'argument' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Argument name when this row describes a field argument; null otherwise.', 'woocommerce' ),
+ ),
+ 'enumValue' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Enum value name when this row describes one specific enum value; null otherwise.', 'woocommerce' ),
+ ),
+ 'entries' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( self::get_entry_type() ) ) ),
+ 'description' => __( 'Metadata entries attached to the target.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$target_type;
+ }
+
+ /**
+ * The `MetadataEntry` output type, lazily built and cached.
+ */
+ private static function get_entry_type(): ObjectType {
+ if ( null === self::$entry_type ) {
+ self::$entry_type = new ObjectType(
+ array(
+ 'name' => 'MetadataEntry',
+ 'description' => __( 'One metadata entry: a `name` plus a scalar `value`.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'Identifier of the entry (e.g. `internal`, `beta`).', 'woocommerce' ),
+ ),
+ 'value' => array(
+ // Nullable: `MetadataValue` itself permits a null payload (e.g.
+ // `#[Metadata( 'deprecated_reason', null )]`), so the wrapping
+ // must allow it through.
+ 'type' => self::get_value_scalar(),
+ 'description' => __( 'Scalar payload associated with the entry. Null when the metadata entry carries a null value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$entry_type;
+ }
+
+ /**
+ * The `MetadataValue` custom scalar, accepting any GraphQL-compatible scalar.
+ *
+ * Webonyx's autogenerated scalar template hard-codes acceptance of string
+ * literals only, so this scalar is hand-built rather than going through
+ * ApiBuilder. `parseLiteral` walks the AST node types and `parseValue`
+ * accepts the already-decoded PHP scalar that variables-mode delivers.
+ */
+ private static function get_value_scalar(): CustomScalarType {
+ if ( null === self::$value_scalar ) {
+ self::$value_scalar = new CustomScalarType(
+ array(
+ 'name' => 'MetadataValue',
+ 'description' => __(
+ 'Scalar payload of a metadata entry. Accepts a string, integer, float, boolean, or null.',
+ 'woocommerce'
+ ),
+ // Resolvers return the raw PHP scalar; webonyx serialises it as JSON directly.
+ 'serialize' => static fn( $value ) => $value,
+ 'parseValue' => static function ( $value ) {
+ if ( null === $value || is_bool( $value ) || is_int( $value ) || is_float( $value ) || is_string( $value ) ) {
+ return $value;
+ }
+ throw new Error( 'MetadataValue must be a string, integer, float, boolean, or null.' );
+ },
+ 'parseLiteral' => static function ( $value_node, ?array $variables = null ) {
+ unset( $variables );
+
+ if ( $value_node instanceof StringValueNode ) {
+ return $value_node->value;
+ }
+ if ( $value_node instanceof BooleanValueNode ) {
+ return $value_node->value;
+ }
+ if ( $value_node instanceof IntValueNode ) {
+ return (int) $value_node->value;
+ }
+ if ( $value_node instanceof FloatValueNode ) {
+ return (float) $value_node->value;
+ }
+ if ( $value_node instanceof NullValueNode ) {
+ return null;
+ }
+ throw new Error( 'MetadataValue must be a string, integer, float, boolean, or null literal.' );
+ },
+ )
+ );
+ }
+ return self::$value_scalar;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php b/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php
index 6e145ea48fd..c9ed0815841 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php
@@ -12,7 +12,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
* documents (keys: `name`, `description`, `serialize`, `parseValue`,
* `parseLiteral`).
*
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
* @internal Reserved for autogenerated resolver code.
*/
class CustomScalarType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\CustomScalarType {
+ /**
+ * Type-level metadata entries declared in the config, keyed by name.
+ *
+ * @return array<string, bool|int|float|string|null>
+ */
+ public function get_metadata(): array {
+ return $this->config['metadata'] ?? array();
+ }
}
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php b/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php
index 67e284188c2..24365992c9b 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php
@@ -10,7 +10,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
* The constructor accepts the same associative-array config webonyx
* documents (keys: `name`, `description`, `values`).
*
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
* @internal Reserved for autogenerated resolver code.
*/
class EnumType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\EnumType {
+ /**
+ * Type-level metadata entries declared in the config, keyed by name.
+ *
+ * @return array<string, bool|int|float|string|null>
+ */
+ public function get_metadata(): array {
+ return $this->config['metadata'] ?? array();
+ }
}
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php b/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php
index cc4d40ed76a..11945a6a442 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php
@@ -11,7 +11,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
* The constructor accepts the same associative-array config webonyx
* documents (keys: `name`, `description`, `fields`).
*
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
* @internal Reserved for autogenerated resolver code.
*/
class InputObjectType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InputObjectType {
+ /**
+ * Type-level metadata entries declared in the config, keyed by name.
+ *
+ * @return array<string, bool|int|float|string|null>
+ */
+ public function get_metadata(): array {
+ return $this->config['metadata'] ?? array();
+ }
}
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php b/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php
index 67b1191f4c7..759b9aa7f1c 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php
@@ -11,7 +11,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
* The constructor accepts the same associative-array config webonyx
* documents (keys: `name`, `description`, `fields`, `resolveType`).
*
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
* @internal Reserved for autogenerated resolver code.
*/
class InterfaceType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InterfaceType {
+ /**
+ * Type-level metadata entries declared in the config, keyed by name.
+ *
+ * @return array<string, bool|int|float|string|null>
+ */
+ public function get_metadata(): array {
+ return $this->config['metadata'] ?? array();
+ }
}
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php b/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php
index 50fc98cb4af..f4693581b42 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php
@@ -13,7 +13,21 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
* `fields` entry is either an array or a callable returning an array of
* field definitions.
*
+ * The wrapper also recognises a `metadata` key — an associative array
+ * mapping metadata `name` => scalar `value` — that ApiBuilder emits for
+ * types carrying {@see \Automattic\WooCommerce\Api\Attributes\Metadata}
+ * attributes. Webonyx ignores unknown config keys, so this rides through
+ * the engine untouched and is surfaced by {@see self::get_metadata()}.
+ *
* @internal Reserved for autogenerated resolver code.
*/
class ObjectType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType {
+ /**
+ * Type-level metadata entries declared in the config, keyed by name.
+ *
+ * @return array<string, bool|int|float|string|null>
+ */
+ public function get_metadata(): array {
+ return $this->config['metadata'] ?? array();
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
index 10692225bb5..216b70fc2cc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
@@ -22,7 +22,7 @@ class CreateWidget {
'input' => array(
'type' => Type::nonNull(CreateWidgetInput::get()),
'description' => __( 'The data for the new widget', 'woocommerce' ),
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
index 62f6dcaff40..efe9ae96bac 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
@@ -21,12 +21,12 @@ class DeleteWidget {
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The widget id to delete', 'woocommerce' ),
- ),
+ ),
'force' => array(
'type' => Type::nonNull(Type::boolean()),
'description' => __( 'When true, ignore \"not found\" errors', 'woocommerce' ),
'defaultValue' => false,
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
index cf84fcfc40d..bd37ee56f13 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
@@ -25,12 +25,12 @@ class Increment {
'value' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The starting value', 'woocommerce' ),
- ),
+ ),
'by' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'How much to add', 'woocommerce' ),
'defaultValue' => 1,
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
index 3f3931282b3..adf93ca4d60 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
@@ -24,7 +24,7 @@ class AuthorizeOnlyQuery {
'args' => array(
'allow' => array(
'type' => Type::nonNull(Type::boolean()),
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
index a1df3ea0f3c..cad9f89e4aa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
@@ -25,7 +25,7 @@ class AuthorizeThrowsQuery {
'kind' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'Which exception class authorize() should raise.', 'woocommerce' ),
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
index 72bbe67efb3..cb7dec96f50 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
@@ -26,7 +26,7 @@ class FailingQuery {
'type' => Type::nonNull(Type::string()),
'description' => __( 'What kind of failure to raise', 'woocommerce' ),
'defaultValue' => 'invalid_argument',
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
index 3e35dfd66c9..2f872f71761 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
@@ -26,7 +26,7 @@ class GetGreeting {
'type' => Type::string(),
'description' => __( 'Who to greet (defaults to \"world\")', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
index f50d55792c9..ad7790e4318 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
@@ -21,7 +21,7 @@ class GetIdentifiable {
'kind' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'Which kind of object to return', 'woocommerce' ),
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
index 967bb912fe2..429b3dcba03 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
@@ -21,7 +21,7 @@ class GetWidget {
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The ID of the widget to fetch', 'woocommerce' ),
- ),
+ ),
),
'resolve' => array( self::class, 'resolve' ),
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
index 72450194515..1dc13852a1f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
@@ -24,37 +24,37 @@ class ListWidgets {
'type' => Type::int(),
'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
'last' => array(
'type' => Type::int(),
'description' => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
'after' => array(
'type' => Type::string(),
'description' => __( 'Return results after this cursor.', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
'before' => array(
'type' => Type::string(),
'description' => __( 'Return results before this cursor.', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
'search' => array(
'type' => Type::string(),
'description' => __( 'A free-text search term', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
'color' => array(
'type' => ColorType::get(),
'description' => __( 'Filter widgets by color', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
'min_priority' => array(
'type' => PriorityType::get(),
'description' => __( 'A second filter applied after the unrolled ones', 'woocommerce' ),
'defaultValue' => NULL,
- ),
+ ),
),
'complexity' => Utils::complexity_from_pagination(...),
'resolve' => array( self::class, 'resolve' ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
index 18975d49020..a09d7074537 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
@@ -21,14 +21,14 @@ class Color {
'RED' => array(
'value' => ColorEnum::Red,
'description' => __( 'Red', 'woocommerce' ),
- ),
+ ),
'GREEN' => array(
'value' => ColorEnum::Green,
'description' => __( 'Green', 'woocommerce' ),
- ),
+ ),
'BLUE' => array(
'value' => ColorEnum::Blue,
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
index 20e02c61827..c2062fd6ce6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
@@ -21,15 +21,15 @@ class Priority {
'LOW' => array(
'value' => PriorityEnum::Low,
'description' => __( 'Low priority', 'woocommerce' ),
- ),
+ ),
'NORMAL_PRIORITY' => array(
'value' => PriorityEnum::Normal,
- ),
+ ),
'HIGH' => array(
'value' => PriorityEnum::High,
'description' => __( 'High priority', 'woocommerce' ),
'deprecationReason' => 'Use NORMAL_PRIORITY instead.',
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
index 5bef5f2d4bf..eee91f9f3f6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
@@ -23,23 +23,23 @@ class CreateWidget {
'label' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'The widget label', 'woocommerce' ),
- ),
+ ),
'weight' => array(
'type' => Type::int(),
'description' => __( 'Optional weight in grams', 'woocommerce' ),
- ),
+ ),
'color' => array(
'type' => Type::nonNull(ColorType::get()),
'description' => __( 'The widget color', 'woocommerce' ),
- ),
+ ),
'tag_ids' => array(
'type' => Type::listOf(Type::nonNull(Type::int())),
'description' => __( 'Tag IDs to attach to the widget', 'woocommerce' ),
- ),
+ ),
'expires_at' => array(
'type' => DummyDateTimeType::get(),
'description' => __( 'When the widget should expire', 'woocommerce' ),
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
index e5c1f42a1b9..5fcae20c1ef 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
@@ -22,11 +22,11 @@ class WidgetFilter {
'search' => array(
'type' => Type::string(),
'description' => __( 'A free-text search term', 'woocommerce' ),
- ),
+ ),
'color' => array(
'type' => ColorType::get(),
'description' => __( 'Filter widgets by color', 'woocommerce' ),
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
index 20b583cf83f..71bb3ba425b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
@@ -22,7 +22,7 @@ class Identifiable {
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The unique numeric identifier', 'woocommerce' ),
- ),
+ ),
),
'resolveType' => function ( $value ) {
$class = get_class( $value );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
index 890f0216b81..563b6b61295 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
@@ -23,11 +23,11 @@ class Named {
'label' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'The display label for this object', 'woocommerce' ),
- ),
+ ),
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The unique numeric identifier', 'woocommerce' ),
- ),
+ ),
),
'resolveType' => function ( $value ) {
$class = get_class( $value );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
index 5dc297f3f23..8a138f3fb80 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
@@ -25,15 +25,15 @@ class Gadget {
'parts_count' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'How many parts the gadget contains', 'woocommerce' ),
- ),
+ ),
'label' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'The display label for this object', 'woocommerce' ),
- ),
+ ),
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The unique numeric identifier', 'woocommerce' ),
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
index c48b7828c2f..cb0e94353b3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
@@ -21,11 +21,11 @@ class OperationResult {
'success' => array(
'type' => Type::nonNull(Type::boolean()),
'description' => __( 'Whether the operation succeeded', 'woocommerce' ),
- ),
+ ),
'message' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'A human-readable status message', 'woocommerce' ),
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
index ee1bf977259..a58f4da92ae 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
@@ -30,59 +30,59 @@ class Widget {
'slug' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'A short slug', 'woocommerce' ),
- ),
+ ),
'caption' => array(
'type' => Type::string(),
'description' => __( 'An optional caption', 'woocommerce' ),
- ),
+ ),
'color' => array(
'type' => Type::nonNull(ColorType::get()),
'description' => __( 'The widget color', 'woocommerce' ),
- ),
+ ),
'priority' => array(
'type' => Type::nonNull(PriorityType::get()),
'description' => __( 'Priority assigned to this widget', 'woocommerce' ),
- ),
+ ),
'tag_ids' => array(
'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))),
'description' => __( 'Tag IDs assigned to this widget', 'woocommerce' ),
- ),
+ ),
'featured_reviews' => array(
'type' => Type::nonNull(Type::listOf(Type::nonNull(WidgetReview::get()))),
'description' => __( 'Notable comments left on this widget', 'woocommerce' ),
- ),
+ ),
'reviews' => array(
'type' => Type::nonNull(WidgetReviewConnectionType::get()),
'description' => __( 'Reviews of the widget', 'woocommerce' ),
- ),
+ ),
'date_created' => array(
'type' => DummyDateTimeType::get(),
'description' => __( 'When the widget was created', 'woocommerce' ),
- ),
+ ),
'price' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'The widget price', 'woocommerce' ),
- 'args' => array(
+ 'args' => array(
'formatted' => array(
'type' => Type::boolean(),
'defaultValue' => false,
'description' => __( 'When true, prepend a $ sign', 'woocommerce' ),
- ),
+ ),
),
),
'legacy_price' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'A field flagged for removal', 'woocommerce' ),
- 'deprecationReason' => 'Use price instead.',
+ 'deprecationReason' => 'Use price instead.',
),
'label' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'The display label for this object', 'woocommerce' ),
- ),
+ ),
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The unique numeric identifier', 'woocommerce' ),
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
index ea3c9cd971e..97b86a77992 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
@@ -25,15 +25,15 @@ class WidgetReview {
'body' => array(
'type' => Type::nonNull(Type::string()),
'description' => __( 'The body of the review', 'woocommerce' ),
- ),
+ ),
'score' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'A score between 0 and 5', 'woocommerce' ),
- ),
+ ),
'id' => array(
'type' => Type::nonNull(Type::int()),
'description' => __( 'The unique numeric identifier', 'woocommerce' ),
- ),
+ ),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
index 1b8a297a8ab..3d9ccc9cd2c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
@@ -21,6 +21,7 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\FailingQuery;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgets;
use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetGreeting;
+use Automattic\WooCommerce\Internal\Api\MetadataController;
use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
class RootQueryType {
@@ -48,6 +49,7 @@ class RootQueryType {
'failing' => FailingQuery::get_field_definition(),
'widgets' => ListWidgets::get_field_definition(),
'greeting' => GetGreeting::get_field_definition(),
+ MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
index 860bcd5e761..2ccbfb4edf3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-06T16:48:37+00:00
\ No newline at end of file
+2026-05-12T08:46:19+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
new file mode 100644
index 00000000000..b2abdd73d52
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\Attributes\Internal;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the {@see Metadata} base attribute and the {@see Internal}
+ * convenience subclass. ApiBuilder identifies metadata-bearing attributes via
+ * `instanceof Metadata`, so the contract these tests pin is the
+ * `get_name()` / `get_value()` pair on the base class.
+ */
+class MetadataAttributeTest extends WC_Unit_Test_Case {
+ /**
+ * @return array<string, array{bool|int|float|string|null}>
+ */
+ public function provider_scalar_values(): array {
+ return array(
+ 'bool true' => array( true ),
+ 'bool false' => array( false ),
+ 'int' => array( 42 ),
+ 'float' => array( 3.14 ),
+ 'string' => array( 'core-team' ),
+ 'null' => array( null ),
+ );
+ }
+
+ /**
+ * @testdox Metadata round-trips name and value for every supported scalar type.
+ *
+ * @dataProvider provider_scalar_values
+ * @param bool|int|float|string|null $value Value to round-trip.
+ */
+ public function test_round_trip_for_scalar_values( bool|int|float|string|null $value ): void {
+ $metadata = new Metadata( 'sample', $value );
+
+ $this->assertSame( 'sample', $metadata->get_name() );
+ $this->assertSame( $value, $metadata->get_value() );
+ }
+
+ /**
+ * @testdox Internal subclass produces a Metadata entry named "internal" with value true.
+ */
+ public function test_internal_subclass_carries_internal_true(): void {
+ $internal = new Internal();
+
+ $this->assertInstanceOf( Metadata::class, $internal );
+ $this->assertSame( 'internal', $internal->get_name() );
+ $this->assertTrue( $internal->get_value() );
+ }
+
+ /**
+ * @testdox Metadata is repeatable so multiple distinct names can decorate one element.
+ */
+ public function test_metadata_attribute_is_repeatable(): void {
+ $reflection = new \ReflectionClass( Metadata::class );
+ $attributes = $reflection->getAttributes( \Attribute::class );
+
+ $this->assertNotEmpty( $attributes, 'Metadata should be decorated with #[Attribute].' );
+
+ $attribute = $attributes[0]->newInstance();
+ $this->assertNotSame( 0, $attribute->flags & \Attribute::IS_REPEATABLE );
+ }
+}