Commit 0b0f8d41423 for woocommerce
commit 0b0f8d414237f62b49bb8799146091e3f7cfbb1c
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Wed Apr 22 11:03:02 2026 +0200
Introduce the dual code + GraphQL API for WooCommerce (#63772)
diff --git a/plugins/woocommerce/.distignore b/plugins/woocommerce/.distignore
index 52942c00671..c74f49cc915 100644
--- a/plugins/woocommerce/.distignore
+++ b/plugins/woocommerce/.distignore
@@ -48,3 +48,4 @@ webpack.config.js
phpstan.neon
phpstan-baseline.neon
/php-stubs/
+/src/Internal/Api/DesignTime/
diff --git a/plugins/woocommerce/changelog/pr-63772 b/plugins/woocommerce/changelog/pr-63772
new file mode 100644
index 00000000000..50618f26ab6
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-63772
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Introduce the dual code + GraphQL API for WooCommerce
diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json
index a4227a86f01..58fd47b50c0 100644
--- a/plugins/woocommerce/composer.json
+++ b/plugins/woocommerce/composer.json
@@ -53,6 +53,7 @@
"composer/installers": "^1.9",
"maxmind-db/reader": "^1.11",
"opis/json-schema": "*",
+ "webonyx/graphql-php": "^15.31",
"woocommerce/action-scheduler": "3.9.3",
"woocommerce/blueprint": "*",
"woocommerce/email-editor": "*",
@@ -92,7 +93,8 @@
"autoload": {
"exclude-from-classmap": [
"includes/legacy",
- "includes/libraries"
+ "includes/libraries",
+ "src/Internal/Api/DesignTime"
],
"classmap": [
"includes/rest-api"
diff --git a/plugins/woocommerce/composer.lock b/plugins/woocommerce/composer.lock
index d1942913dd6..f8d804eac2e 100644
--- a/plugins/woocommerce/composer.lock
+++ b/plugins/woocommerce/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c9dcd2cbeb75aa25b1b43c237292c908",
+ "content-hash": "b8069038c6b35a5f88ea33f3b60e9c08",
"packages": [
{
"name": "automattic/block-delimiter",
@@ -1068,6 +1068,86 @@
},
"time": "2021-05-22T15:57:08+00:00"
},
+ {
+ "name": "webonyx/graphql-php",
+ "version": "v15.32.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webonyx/graphql-php.git",
+ "reference": "e8f77f81dbe5de75551137955dd0fd3f779235cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/e8f77f81dbe5de75551137955dd0fd3f779235cf",
+ "reference": "e8f77f81dbe5de75551137955dd0fd3f779235cf",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "php": "^7.4 || ^8"
+ },
+ "require-dev": {
+ "amphp/amp": "^2.6 || ^3",
+ "amphp/http-server": "^2.1 || ^3",
+ "dms/phpunit-arraysubset-asserts": "dev-master",
+ "ergebnis/composer-normalize": "^2.28",
+ "friendsofphp/php-cs-fixer": "3.95.1",
+ "mll-lab/php-cs-fixer-config": "5.13.0",
+ "nyholm/psr7": "^1.5",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "2.1.46",
+ "phpstan/phpstan-phpunit": "2.0.16",
+ "phpstan/phpstan-strict-rules": "2.0.10",
+ "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
+ "psr/http-message": "^1 || ^2",
+ "react/http": "^1.6",
+ "react/promise": "^2.0 || ^3.0",
+ "rector/rector": "^2.0",
+ "symfony/polyfill-php81": "^1.23",
+ "symfony/var-exporter": "^5 || ^6 || ^7 || ^8",
+ "thecodingmachine/safe": "^1.3 || ^2 || ^3",
+ "ticketswap/phpstan-error-formatter": "1.3.0"
+ },
+ "suggest": {
+ "amphp/amp": "To leverage async resolving on AMPHP platform (v3 with AmpFutureAdapter, v2 with AmpPromiseAdapter)",
+ "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
+ "psr/http-message": "To use standard GraphQL server",
+ "react/promise": "To leverage async resolving on React PHP platform"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "GraphQL\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A PHP port of GraphQL reference implementation",
+ "homepage": "https://github.com/webonyx/graphql-php",
+ "keywords": [
+ "api",
+ "graphql"
+ ],
+ "support": {
+ "issues": "https://github.com/webonyx/graphql-php/issues",
+ "source": "https://github.com/webonyx/graphql-php/tree/v15.32.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spawnia",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/webonyx-graphql-php",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2026-04-21T09:42:39+00:00"
+ },
{
"name": "woocommerce/action-scheduler",
"version": "3.9.3",
@@ -5596,5 +5676,5 @@
"platform-overrides": {
"php": "7.4"
},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 151caa536bd..68b0ee30a36 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -412,6 +412,9 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\ProductFilters\MainQueryController::class )->register();
$container->get( Automattic\WooCommerce\Internal\ProductFilters\CacheController::class )->register();
+ // Code+GraphQL API.
+ Automattic\WooCommerce\Internal\Api\Main::register();
+
// Integration point between legacy reports and orders APIs (the reports caches invalidation focused).
\WC_Admin_Reports::register_orders_hook_handlers();
}
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index fdd81b01c8d..72f7d2e774e 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -22,6 +22,8 @@
"build:project:copy-assets:email-editor": "rsync -avhW --checksum --delete --quiet ../../packages/php/email-editor/src/ packages/email-editor/src",
"build:project:copy-assets:blueprint": "rsync -avhW --checksum --delete --quiet ../../packages/php/blueprint/src/ packages/blueprint/src",
"build:project:actualize-translation-domains": "wireit",
+ "build:api": "php src/Internal/Api/DesignTime/Scripts/build-api.php",
+ "build:api:check": "php src/Internal/Api/DesignTime/Scripts/check-api-staleness.php",
"changelog": "XDEBUG_MODE=off composer install --quiet && composer exec -- changelogger",
"update:php": "XDEBUG_MODE=off composer update --quiet",
"env:destroy": "pnpm wp-env destroy",
@@ -99,6 +101,9 @@
"wp-env": "wp-env"
},
"lint-staged": {
+ "src/Api/**/*.php": [
+ "php src/Internal/Api/DesignTime/Scripts/check-api-staleness.php"
+ ],
"*.php": [
"php -d display_errors=1 -l",
"composer run-script lint-staged"
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 342ad0d8cbc..8a2c570e4b8 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -55,6 +55,48 @@
<rule ref="PHPCompatibility">
<exclude-pattern>tests/</exclude-pattern>
+ <exclude-pattern>src/Api/</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/</exclude-pattern>
+ </rule>
+
+ <!-- The Code API and its infrastructure require PHP 8.1+. CI runs on PHP 7.4,
+ so Generic.PHP.Syntax (which shells out to `php -l`) flags every enum,
+ constructor promotion, named argument, and union type as a parse error. -->
+ <rule ref="Generic.PHP.Syntax">
+ <exclude-pattern>src/Api/</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/</exclude-pattern>
+ </rule>
+
+ <!-- PHP 8.0 `mixed` type hint is valid but not recognized by the Squiz sniff -->
+ <rule ref="Squiz.Commenting.FunctionComment.InvalidTypeHint">
+ <exclude-pattern>src/Api/</exclude-pattern>
+ </rule>
+
+ <!-- tax_query / meta_query are intentional for product filtering -->
+ <rule ref="WordPress.DB.SlowDBQuery">
+ <exclude-pattern>src/Api/</exclude-pattern>
+ </rule>
+
+ <!-- API public classes: suppress variable comments where #[Description] attributes serve as documentation -->
+ <rule ref="Squiz.Commenting.VariableComment.Missing">
+ <exclude-pattern>src/Api/Types/</exclude-pattern>
+ <exclude-pattern>src/Api/InputTypes/</exclude-pattern>
+ <exclude-pattern>src/Api/Pagination/</exclude-pattern>
+ </rule>
+
+ <!-- Cursor-based pagination legitimately uses base64 for opaque cursors -->
+ <rule ref="WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode">
+ <exclude-pattern>src/Api/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode">
+ <exclude-pattern>src/Api/</exclude-pattern>
+ </rule>
+
+ <!-- Reserved keyword parameter names ($type, $default, $class, $parent) are
+ unavoidable in attribute definitions, autoloader closures, and generated code. -->
+ <rule ref="Universal.NamingConventions.NoReservedKeywordParameterNames">
+ <exclude-pattern>src/Api/Attributes/Parameter.php</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/</exclude-pattern>
</rule>
<rule ref="Suin.Classes.PSR4">
@@ -161,6 +203,117 @@
<exclude-pattern>tests/php/</exclude-pattern>
</rule>
+ <!-- LongConditionClosingComment wants `//end if` / `//end foreach` markers on
+ long blocks. Archaic convention from legacy WP code that's not used in
+ modern PHP 8.1+ code paths like the Code API and its infrastructure. -->
+ <rule ref="Squiz.Commenting.LongConditionClosingComment">
+ <exclude-pattern>src/Api/</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/</exclude-pattern>
+ </rule>
+
+ <!-- Autogenerated API code: suppress rules for generated files -->
+ <rule ref="Generic.Commenting">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ </rule>
+ <rule ref="Squiz.Commenting">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.Security.EscapeOutput">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.WP.AlternativeFunctions">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ </rule>
+ <rule ref="Generic.CodeAnalysis.UnusedFunctionParameter">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/GraphQLController.php</exclude-pattern>
+ <exclude-pattern>src/Internal/Api/QueryInfoExtractor.php</exclude-pattern>
+ </rule>
+
+ <!-- API build scripts use empty if/elseif for intentional no-ops (e.g. enum
+ properties need no conversion) with a comment explaining why. -->
+ <rule ref="Generic.CodeAnalysis.EmptyStatement">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+
+ <!-- Autogenerated code: suppress additional rules -->
+ <rule ref="Generic.PHP.Syntax">
+ <exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
+ </rule>
+
+ <!-- API templates: suppress rules that don't apply to PHP templates -->
+ <rule ref="Generic.PHP.RequireStrictTypes">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="PSR12.Files.FileHeader">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="Generic.Commenting">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="Squiz.Commenting">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.DiscouragedPHPFunctions.serialize_var_export">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.CodeAnalysis.AssignmentInTernaryCondition">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.DontExtract">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.Security.EscapeOutput">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.YodaConditions">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.DevelopmentFunctions">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="Generic.WhiteSpace.ScopeIndent">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.WP.GlobalVariablesOverride">
+ <exclude-pattern>src/Internal/Api/DesignTime/Templates/</exclude-pattern>
+ </rule>
+
+ <!-- API build scripts: suppress WordPress-specific rules (CLI-only code) -->
+ <rule ref="WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.WP.AlternativeFunctions">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.Security.EscapeOutput">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.YodaConditions">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.DontExtract">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="WordPress.PHP.DevelopmentFunctions">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="Generic.Commenting">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="Squiz.Commenting">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="Universal.NamingConventions.NoReservedKeywordParameterNames">
+ <exclude-pattern>src/Internal/Api/DesignTime/Scripts/</exclude-pattern>
+ </rule>
+ <rule ref="WooCommerce.Functions.InternalInjectionMethod">
+ <exclude-pattern>src/Internal/Api/DesignTime/</exclude-pattern>
+ </rule>
+
<!-- Temporary -->
<rule ref="Universal.Arrays.DisallowShortArraySyntax.Found">
<exclude-pattern>src/Blocks/</exclude-pattern>
diff --git a/plugins/woocommerce/phpstan.neon b/plugins/woocommerce/phpstan.neon
index 7f89cdd47b6..cf81175dfb3 100644
--- a/plugins/woocommerce/phpstan.neon
+++ b/plugins/woocommerce/phpstan.neon
@@ -14,6 +14,14 @@ parameters:
# Matches the prior test implementation; GeoIP relies on data files.
- includes/class-wc-geo-ip.php
- includes/react-admin/feature-config.php (?)
+ # The Code API (src/Api/) and its infrastructure (src/Internal/Api/)
+ # require PHP 8.1+ and use enums, named arguments, constructor
+ # property promotion, union types, and `mixed` throughout — all of
+ # which the global phpVersion: 70400 setting cannot parse or resolve.
+ # Infrastructure files also reference src/Api/ classes that PHPStan
+ # can't discover once the public API dir is excluded.
+ - src/Api/
+ - src/Internal/Api/
bootstrapFiles:
- vendor/autoload.php
scanDirectories:
diff --git a/plugins/woocommerce/src/Api/ApiException.php b/plugins/woocommerce/src/Api/ApiException.php
new file mode 100644
index 00000000000..1b0d58816fd
--- /dev/null
+++ b/plugins/woocommerce/src/Api/ApiException.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Exception for API errors with error codes and extensions.
+ */
+class ApiException extends \RuntimeException {
+ /**
+ * Constructor.
+ *
+ * @param string $message The error message.
+ * @param string $error_code The machine-readable error code.
+ * @param array $extensions Additional error metadata.
+ * @param int $status_code The HTTP status code.
+ * @param ?\Throwable $previous The previous throwable for chaining.
+ */
+ public function __construct(
+ string $message,
+ private readonly string $error_code = 'INTERNAL_ERROR',
+ private readonly array $extensions = array(),
+ int $status_code = 500,
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct( $message, $status_code, $previous );
+ }
+
+ /**
+ * Get the machine-readable error code.
+ *
+ * @return string
+ */
+ public function getErrorCode(): string {
+ return $this->error_code;
+ }
+
+ /**
+ * Get the additional error metadata.
+ *
+ * @return array
+ */
+ public function getExtensions(): array {
+ return $this->extensions;
+ }
+
+ /**
+ * Get the HTTP status code.
+ *
+ * @return int
+ */
+ public function getStatusCode(): int {
+ return $this->getCode();
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ArrayOf.php b/plugins/woocommerce/src/Api/Attributes/ArrayOf.php
new file mode 100644
index 00000000000..dd58ea81058
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ArrayOf.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares the element type for an array-typed property or return value.
+ *
+ * PHP arrays are untyped, so the builder cannot infer the element type via
+ * reflection. Apply this attribute to tell the builder what GraphQL list type
+ * to generate (e.g. `[Int!]`, `[String!]`).
+ *
+ * Example: `#[ArrayOf('int')]` on a `array $product_ids` property produces
+ * the GraphQL type `[Int!]!`.
+ */
+#[Attribute]
+final class ArrayOf {
+ /**
+ * Constructor.
+ *
+ * @param string $type A scalar name ('int', 'string', 'float', 'bool') or
+ * a fully-qualified class name for output/enum types.
+ */
+ public function __construct(
+ public readonly string $type,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ConnectionOf.php b/plugins/woocommerce/src/Api/Attributes/ConnectionOf.php
new file mode 100644
index 00000000000..ae37a02ed95
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ConnectionOf.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a query's return type as a Relay-style connection of the given node type.
+ *
+ * Applied to the `execute()` method of a query class that returns a `Connection`.
+ * The builder uses this to generate the corresponding connection and edge GraphQL
+ * types (e.g. `CouponConnection`, `CouponEdge`) and to wire the correct return
+ * type in the schema.
+ */
+#[Attribute]
+final class ConnectionOf {
+ /**
+ * Constructor.
+ *
+ * @param string $type The fully-qualified class name of the node type
+ * (e.g. `Coupon::class`).
+ */
+ public function __construct(
+ public readonly string $type,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Deprecated.php b/plugins/woocommerce/src/Api/Attributes/Deprecated.php
new file mode 100644
index 00000000000..5cf48e0af38
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Deprecated.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a field or enum value as deprecated in the GraphQL schema.
+ *
+ * Deprecated elements remain functional but are flagged with a deprecation
+ * reason in introspection, signaling to API consumers that they should
+ * migrate to an alternative.
+ */
+#[Attribute]
+final class Deprecated {
+ /**
+ * Constructor.
+ *
+ * @param string $reason A human-readable explanation of why the element is
+ * deprecated and what to use instead.
+ */
+ public function __construct(
+ public readonly string $reason,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Description.php b/plugins/woocommerce/src/Api/Attributes/Description.php
new file mode 100644
index 00000000000..1cc20cf6672
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Description.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Provides a human-readable description for the annotated element.
+ *
+ * Can be applied to classes (types, queries, mutations, enums), properties, or
+ * parameters. The text is exposed as the "description" field in the generated
+ * GraphQL schema and is visible in tools like GraphiQL.
+ */
+#[Attribute]
+final class Description {
+ /**
+ * Constructor.
+ *
+ * @param string $description The text to expose as the GraphQL description.
+ */
+ public function __construct(
+ public readonly string $description,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Ignore.php b/plugins/woocommerce/src/Api/Attributes/Ignore.php
new file mode 100644
index 00000000000..df09b650e6a
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Ignore.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Tells the builder to skip the annotated element entirely.
+ *
+ * Apply to a class to exclude it from API discovery (e.g. helper classes that
+ * live in a scanned namespace but are not part of the API), or to a property
+ * to omit it from the generated GraphQL type.
+ */
+#[Attribute]
+final class Ignore {
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Name.php b/plugins/woocommerce/src/Api/Attributes/Name.php
new file mode 100644
index 00000000000..a0c5f3d325b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Name.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Overrides the GraphQL name derived from the PHP class or property name.
+ *
+ * By default the builder converts PHP names to GraphQL conventions automatically.
+ * Use this attribute when you need a specific GraphQL name that differs from
+ * the default conversion (e.g. a legacy name for backwards compatibility).
+ */
+#[Attribute]
+final class Name {
+ /**
+ * Constructor.
+ *
+ * @param string $name The exact name to use in the GraphQL schema.
+ */
+ public function __construct(
+ public readonly string $name,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Parameter.php b/plugins/woocommerce/src/Api/Attributes/Parameter.php
new file mode 100644
index 00000000000..c6d7ec15c5c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Parameter.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares an explicit GraphQL argument for a query or mutation.
+ *
+ * Use this when the argument cannot be inferred from the `execute()` method
+ * signature — for example, when a parameter needs a specific GraphQL type,
+ * nullability, or default that differs from what reflection would produce.
+ * This attribute is repeatable: apply it once per argument.
+ */
+#[Attribute( Attribute::TARGET_ALL | Attribute::IS_REPEATABLE )]
+final class Parameter {
+ /**
+ * Whether a default value was provided.
+ *
+ * @var bool
+ */
+ public readonly bool $has_default;
+
+ /**
+ * Constructor.
+ *
+ * @param string $name The GraphQL argument name (not needed when unrolling).
+ * @param string $type The PHP type name ('int', 'string', 'float', 'bool')
+ * or a fully-qualified class name for complex types.
+ * @param bool $nullable Whether the argument accepts null.
+ * @param bool $array Whether the argument is a list (e.g. `[Int!]`).
+ * @param mixed $default The default value if the argument is omitted.
+ * @param string $description Human-readable description for the schema.
+ * @param bool $has_default Set to true to explicitly indicate a default is
+ * provided (needed when the default value is null).
+ * @param bool $unroll When true, the class given in $type is expanded into
+ * individual GraphQL arguments (one per public property).
+ */
+ public function __construct(
+ public readonly string $name = '',
+ public readonly string $type = '',
+ public readonly bool $nullable = false,
+ public readonly bool $array = false,
+ public readonly mixed $default = null,
+ public readonly string $description = '',
+ bool $has_default = false,
+ public readonly bool $unroll = false,
+ ) {
+ // We need a separate flag because null could be a valid default value.
+ // Callers pass has_default: true when they supply a default, or we infer
+ // it from the default value being non-null.
+ $this->has_default = $has_default || null !== $default;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ParameterDescription.php b/plugins/woocommerce/src/Api/Attributes/ParameterDescription.php
new file mode 100644
index 00000000000..0ee3816d420
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ParameterDescription.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Adds a description to a query/mutation argument without overriding its type.
+ *
+ * Sets the description for a query/mutation argument. Can be used both for
+ * arguments inferred from the `execute()` method signature and for arguments
+ * declared via #[Parameter]. However, a parameter must not have a description
+ * in both #[Parameter] and #[ParameterDescription] — that is a build error.
+ * This attribute is repeatable: apply it once per argument that needs a
+ * description.
+ */
+#[Attribute( Attribute::TARGET_ALL | Attribute::IS_REPEATABLE )]
+final class ParameterDescription {
+ /**
+ * Constructor.
+ *
+ * @param string $name The argument name (must match the `execute()`
+ * parameter name).
+ * @param string $description Human-readable description for the schema.
+ */
+ public function __construct(
+ public readonly string $name,
+ public readonly string $description,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
new file mode 100644
index 00000000000..ef9b5d51257
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a query or mutation as publicly accessible without authentication.
+ *
+ * When present, the generated resolver skips all capability checks, allowing
+ * any user (including unauthenticated visitors) to execute the operation.
+ *
+ * Mutually exclusive with #[RequiredCapability] on the same class.
+ */
+#[Attribute( Attribute::TARGET_CLASS )]
+final class PublicAccess {
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
new file mode 100644
index 00000000000..27459014277
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares a WordPress capability required to execute a query or mutation.
+ *
+ * The generated resolver checks `current_user_can()` for every declared
+ * capability before invoking the command. If any check fails, an
+ * UNAUTHORIZED error is returned. This attribute is repeatable: apply it
+ * multiple times to require several capabilities.
+ *
+ * Mutually exclusive with #[PublicAccess] on the same class.
+ */
+#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS )]
+final class RequiredCapability {
+ /**
+ * Constructor.
+ *
+ * @param string $capability A WordPress capability slug
+ * (e.g. 'manage_woocommerce').
+ */
+ public function __construct(
+ public readonly string $capability,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ReturnType.php b/plugins/woocommerce/src/Api/Attributes/ReturnType.php
new file mode 100644
index 00000000000..e2eae114d37
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ReturnType.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Declares the GraphQL return type of execute() when it returns an interface.
+ *
+ * Since PHP cannot type-hint a trait, the execute() method uses `object` as its
+ * return type and this attribute tells the builder which interface type to use
+ * in the schema. The GraphQL engine then uses the interface's `resolveType`
+ * callback to determine the concrete type at runtime.
+ */
+#[Attribute( Attribute::TARGET_METHOD )]
+final class ReturnType {
+ /**
+ * Constructor.
+ *
+ * @param string $type The fully-qualified class name of the interface trait
+ * (e.g. `ApiObject::class`).
+ */
+ public function __construct(
+ public readonly string $type,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/ScalarType.php b/plugins/woocommerce/src/Api/Attributes/ScalarType.php
new file mode 100644
index 00000000000..074b759179e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/ScalarType.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Overrides the GraphQL type for a property with a custom scalar.
+ *
+ * By default the builder maps PHP types to built-in GraphQL scalars (String,
+ * Int, Float, Boolean). Use this attribute when a property should use a custom
+ * scalar type instead, such as `DateTime`.
+ *
+ * Example: `#[ScalarType(DateTime::class)]` on a `?string $date_created`
+ * property produces the GraphQL type `DateTime` instead of `String`.
+ */
+#[Attribute]
+final class ScalarType {
+ /**
+ * Constructor.
+ *
+ * @param string $type The fully-qualified class name of the custom scalar
+ * (e.g. `DateTime::class`).
+ */
+ public function __construct(
+ public readonly string $type,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Unroll.php b/plugins/woocommerce/src/Api/Attributes/Unroll.php
new file mode 100644
index 00000000000..478ee9b79a0
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Unroll.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Expands a class's properties into individual flat GraphQL arguments.
+ *
+ * When applied to a class, any `execute()` parameter of that type is
+ * automatically unrolled. When applied to a specific `execute()` parameter,
+ * only that usage is unrolled.
+ *
+ * Each public property of the target class becomes a separate GraphQL argument.
+ * Properties marked with #[Ignore] are skipped, and #[Description] on
+ * properties is forwarded to the generated argument descriptions.
+ *
+ * The generated resolver constructs the original class via its constructor,
+ * passing the individual argument values as named parameters.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PARAMETER )]
+final class Unroll {
+}
diff --git a/plugins/woocommerce/src/Api/AuthorizationException.php b/plugins/woocommerce/src/Api/AuthorizationException.php
new file mode 100644
index 00000000000..d8cc405d2b4
--- /dev/null
+++ b/plugins/woocommerce/src/Api/AuthorizationException.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown from an authorize() method to deny access with a custom error message.
+ *
+ * Uses a fixed UNAUTHORIZED error code and 401 status. The message defaults to
+ * a generic denial but can be overridden for more specific feedback.
+ */
+class AuthorizationException extends ApiException {
+ /**
+ * Constructor.
+ *
+ * @param string $message The error message.
+ * @param ?\Throwable $previous The previous throwable for chaining.
+ */
+ public function __construct(
+ string $message = 'You do not have permission to perform this action.',
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct( $message, 'UNAUTHORIZED', array(), 401, $previous );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Coupons/CouponStatus.php b/plugins/woocommerce/src/Api/Enums/Coupons/CouponStatus.php
new file mode 100644
index 00000000000..106cc6e2001
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Coupons/CouponStatus.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The publication status of a coupon.' )]
+enum CouponStatus: string {
+ #[Description( 'The coupon is published and active.' )]
+ case Published = 'publish';
+
+ #[Description( 'The coupon is a draft.' )]
+ case Draft = 'draft';
+
+ #[Description( 'The coupon is pending review.' )]
+ case Pending = 'pending';
+
+ #[Description( 'The coupon is privately published.' )]
+ case Private = 'private';
+
+ #[Description( 'The coupon is scheduled to be published in the future.' )]
+ case Future = 'future';
+
+ #[Description( 'The coupon is in the trash.' )]
+ case Trash = 'trash';
+
+ #[Description( 'The coupon status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.' )]
+ case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Coupons/DiscountType.php b/plugins/woocommerce/src/Api/Enums/Coupons/DiscountType.php
new file mode 100644
index 00000000000..6ac7e353b90
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Coupons/DiscountType.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The type of discount for a coupon.' )]
+enum DiscountType: string {
+ #[Description( 'A percentage discount.' )]
+ case Percent = 'percent';
+
+ #[Description( 'A fixed amount discount applied to the cart.' )]
+ case FixedCart = 'fixed_cart';
+
+ #[Description( 'A fixed amount discount applied to each eligible product.' )]
+ case FixedProduct = 'fixed_product';
+
+ #[Description( 'The discount type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_discount_type for the underlying value.' )]
+ case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Products/ProductStatus.php b/plugins/woocommerce/src/Api/Enums/Products/ProductStatus.php
new file mode 100644
index 00000000000..0c64e7bb2d0
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Products/ProductStatus.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+
+#[Description( 'The publication status of a product.' )]
+enum ProductStatus: string {
+ #[Description( 'The product is a draft.' )]
+ case Draft = 'draft';
+
+ #[Description( 'The product is pending review.' )]
+ case Pending = 'pending';
+
+ #[Name( 'ACTIVE' )]
+ #[Description( 'The product is published and visible.' )]
+ case Published = 'publish';
+
+ #[Description( 'The product is privately published.' )]
+ case Private = 'private';
+
+ #[Description( 'The product is scheduled to be published in the future.' )]
+ case Future = 'future';
+
+ #[Deprecated( 'Trashed products should be excluded via status filter.' )]
+ #[Description( 'The product is in the trash.' )]
+ case Trash = 'trash';
+
+ #[Description( 'The product status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.' )]
+ case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Products/ProductType.php b/plugins/woocommerce/src/Api/Enums/Products/ProductType.php
new file mode 100644
index 00000000000..32a94190596
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Products/ProductType.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The type of a WooCommerce product.' )]
+enum ProductType: string {
+ #[Description( 'A simple product.' )]
+ case Simple = 'simple';
+
+ #[Description( 'A grouped product.' )]
+ case Grouped = 'grouped';
+
+ #[Description( 'An external/affiliate product.' )]
+ case External = 'external';
+
+ #[Description( 'A variable product with variations.' )]
+ case Variable = 'variable';
+
+ #[Description( 'A product variation.' )]
+ case Variation = 'variation';
+
+ #[Description( 'The product type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_product_type for the underlying value.' )]
+ case Other = 'other';
+}
diff --git a/plugins/woocommerce/src/Api/Enums/Products/StockStatus.php b/plugins/woocommerce/src/Api/Enums/Products/StockStatus.php
new file mode 100644
index 00000000000..8e1a6c3ebc7
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Enums/Products/StockStatus.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Enums\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+#[Description( 'The stock status of a product.' )]
+enum StockStatus: int {
+ #[Description( 'The product is in stock.' )]
+ case InStock = 1;
+
+ #[Description( 'The product is out of stock.' )]
+ case OutOfStock = 2;
+
+ #[Description( 'The product is on backorder.' )]
+ case OnBackorder = 3;
+
+ #[Description( 'The stock status is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_stock_status for the underlying value.' )]
+ case Other = 4;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Coupons/CreateCouponInput.php b/plugins/woocommerce/src/Api/InputTypes/Coupons/CreateCouponInput.php
new file mode 100644
index 00000000000..1ec7ea7c38f
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Coupons/CreateCouponInput.php
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for creating a coupon.
+ */
+#[Description( 'Data required to create a new coupon.' )]
+class CreateCouponInput {
+ use TracksProvidedFields;
+
+ #[Description( 'The coupon code.' )]
+ public string $code;
+
+ #[Description( 'The coupon description.' )]
+ public ?string $description = null;
+
+ #[Description( 'The type of discount.' )]
+ public ?DiscountType $discount_type = null;
+
+ #[Description( 'The discount amount.' )]
+ public ?float $amount = null;
+
+ #[Description( 'The coupon status.' )]
+ public ?CouponStatus $status = null;
+
+ #[Description( 'The date the coupon expires (ISO 8601).' )]
+ public ?string $date_expires = null;
+
+ #[Description( 'Whether the coupon can only be used alone.' )]
+ public ?bool $individual_use = null;
+
+ #[Description( 'Product IDs the coupon can be applied to.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $product_ids = null;
+
+ #[Description( 'Product IDs excluded from the coupon.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $excluded_product_ids = null;
+
+ #[Description( 'Maximum number of times the coupon can be used in total.' )]
+ public ?int $usage_limit = null;
+
+ #[Description( 'Maximum number of times the coupon can be used per customer.' )]
+ public ?int $usage_limit_per_user = null;
+
+ #[Description( 'Maximum number of items the coupon can be applied to.' )]
+ public ?int $limit_usage_to_x_items = null;
+
+ #[Description( 'Whether the coupon grants free shipping.' )]
+ public ?bool $free_shipping = null;
+
+ #[Description( 'Product category IDs the coupon applies to.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $product_categories = null;
+
+ #[Description( 'Product category IDs excluded from the coupon.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $excluded_product_categories = null;
+
+ #[Description( 'Whether the coupon excludes items on sale.' )]
+ public ?bool $exclude_sale_items = null;
+
+ #[Description( 'Minimum order amount required to use the coupon.' )]
+ public ?float $minimum_amount = null;
+
+ #[Description( 'Maximum order amount allowed to use the coupon.' )]
+ public ?float $maximum_amount = null;
+
+ #[Description( 'Email addresses that can use this coupon.' )]
+ #[ArrayOf( 'string' )]
+ public ?array $email_restrictions = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Coupons/UpdateCouponInput.php b/plugins/woocommerce/src/Api/InputTypes/Coupons/UpdateCouponInput.php
new file mode 100644
index 00000000000..587ec9e0764
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Coupons/UpdateCouponInput.php
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for updating a coupon.
+ */
+#[Description( 'Data for updating an existing coupon. All fields are optional.' )]
+class UpdateCouponInput {
+ use TracksProvidedFields;
+
+ #[Description( 'The ID of the coupon to update.' )]
+ public int $id;
+
+ #[Description( 'The coupon code.' )]
+ public ?string $code = null;
+
+ #[Description( 'The coupon description.' )]
+ public ?string $description = null;
+
+ #[Description( 'The type of discount.' )]
+ public ?DiscountType $discount_type = null;
+
+ #[Description( 'The discount amount.' )]
+ public ?float $amount = null;
+
+ #[Description( 'The coupon status.' )]
+ public ?CouponStatus $status = null;
+
+ #[Description( 'The date the coupon expires (ISO 8601).' )]
+ public ?string $date_expires = null;
+
+ #[Description( 'Whether the coupon can only be used alone.' )]
+ public ?bool $individual_use = null;
+
+ #[Description( 'Product IDs the coupon can be applied to.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $product_ids = null;
+
+ #[Description( 'Product IDs excluded from the coupon.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $excluded_product_ids = null;
+
+ #[Description( 'Maximum number of times the coupon can be used in total.' )]
+ public ?int $usage_limit = null;
+
+ #[Description( 'Maximum number of times the coupon can be used per customer.' )]
+ public ?int $usage_limit_per_user = null;
+
+ #[Description( 'Maximum number of items the coupon can be applied to.' )]
+ public ?int $limit_usage_to_x_items = null;
+
+ #[Description( 'Whether the coupon grants free shipping.' )]
+ public ?bool $free_shipping = null;
+
+ #[Description( 'Product category IDs the coupon applies to.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $product_categories = null;
+
+ #[Description( 'Product category IDs excluded from the coupon.' )]
+ #[ArrayOf( 'int' )]
+ public ?array $excluded_product_categories = null;
+
+ #[Description( 'Whether the coupon excludes items on sale.' )]
+ public ?bool $exclude_sale_items = null;
+
+ #[Description( 'Minimum order amount required to use the coupon.' )]
+ public ?float $minimum_amount = null;
+
+ #[Description( 'Maximum order amount allowed to use the coupon.' )]
+ public ?float $maximum_amount = null;
+
+ #[Description( 'Email addresses that can use this coupon.' )]
+ #[ArrayOf( 'string' )]
+ public ?array $email_restrictions = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/BaseProductInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/BaseProductInput.php
new file mode 100644
index 00000000000..5dca72068ac
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/BaseProductInput.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Shared fields for product creation and update input types.
+ */
+abstract class BaseProductInput {
+ use TracksProvidedFields;
+
+ #[Description( 'The product slug.' )]
+ public ?string $slug = null;
+
+ #[Description( 'The product SKU.' )]
+ public ?string $sku = null;
+
+ #[Description( 'The full product description.' )]
+ public ?string $description = null;
+
+ #[Description( 'The short product description.' )]
+ public ?string $short_description = null;
+
+ #[Description( 'The product status.' )]
+ public ?ProductStatus $status = null;
+
+ #[Description( 'The product type.' )]
+ public ?ProductType $product_type = null;
+
+ #[Description( 'The regular price.' )]
+ public ?float $regular_price = null;
+
+ #[Description( 'The sale price.' )]
+ public ?float $sale_price = null;
+
+ #[Description( 'Whether to manage stock.' )]
+ public ?bool $manage_stock = null;
+
+ #[Description( 'The number of items in stock.' )]
+ public ?int $stock_quantity = null;
+
+ #[Description( 'The product dimensions.' )]
+ public ?DimensionsInput $dimensions = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/CreateProductInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/CreateProductInput.php
new file mode 100644
index 00000000000..b115190532b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/CreateProductInput.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Input type for creating a product.
+ */
+#[Description( 'Data required to create a new product.' )]
+class CreateProductInput extends BaseProductInput {
+ #[Description( 'The product name.' )]
+ public string $name;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/DimensionsInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/DimensionsInput.php
new file mode 100644
index 00000000000..f1b50053374
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/DimensionsInput.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for product dimensions.
+ */
+#[Description( 'Physical dimensions and weight for a product.' )]
+class DimensionsInput {
+ use TracksProvidedFields;
+
+ #[Description( 'The product length.' )]
+ public ?float $length = null;
+
+ #[Description( 'The product width.' )]
+ public ?float $width = null;
+
+ #[Description( 'The product height.' )]
+ public ?float $height = null;
+
+ #[Description( 'The product weight.' )]
+ public ?float $weight = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/ProductFilterInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/ProductFilterInput.php
new file mode 100644
index 00000000000..0b9f0802e4c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/ProductFilterInput.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
+
+/**
+ * Input type for filtering products.
+ *
+ * Used with parameter-level #[Unroll] to expand fields as direct query arguments.
+ * Uses constructor promotion so the builder can instantiate it via named arguments.
+ */
+#[Description( 'Filter criteria for listing products.' )]
+class ProductFilterInput {
+ use TracksProvidedFields;
+
+ /**
+ * Constructor.
+ *
+ * @param ?ProductStatus $status Filter by product status.
+ * @param ?StockStatus $stock_status Filter by stock status.
+ * @param ?string $search Search products by keyword.
+ */
+ public function __construct(
+ #[Description( 'Filter by product status.' )]
+ public readonly ?ProductStatus $status = null,
+ #[Description( 'Filter by stock status.' )]
+ public readonly ?StockStatus $stock_status = null,
+ #[Description( 'Search products by keyword.' )]
+ public readonly ?string $search = null,
+ ) {
+ }
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/Products/UpdateProductInput.php b/plugins/woocommerce/src/Api/InputTypes/Products/UpdateProductInput.php
new file mode 100644
index 00000000000..4f0e13f5b3c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/Products/UpdateProductInput.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Input type for updating a product.
+ */
+#[Description( 'Data for updating an existing product.' )]
+class UpdateProductInput extends BaseProductInput {
+ #[Description( 'The ID of the product to update.' )]
+ public int $id;
+
+ #[Description( 'The product name.' )]
+ public ?string $name = null;
+}
diff --git a/plugins/woocommerce/src/Api/InputTypes/TracksProvidedFields.php b/plugins/woocommerce/src/Api/InputTypes/TracksProvidedFields.php
new file mode 100644
index 00000000000..fdb8beb78fc
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InputTypes/TracksProvidedFields.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\InputTypes;
+
+/**
+ * Trait for input types to track which fields were explicitly provided in the GraphQL request.
+ *
+ * This allows mutations to distinguish between a field being missing (don't change it)
+ * and explicitly set to null (clear it).
+ */
+trait TracksProvidedFields {
+ /**
+ * Fields that were explicitly provided in the input.
+ *
+ * Using an underscore prefix to keep it invisible to the ApiBuilder
+ * (which only scans public properties for GraphQL fields).
+ *
+ * @var array<string, true>
+ */
+ protected array $provided_fields = array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase -- internal tracking array
+
+ /**
+ * Mark a field as explicitly provided in the input.
+ *
+ * @param string $field The field name.
+ */
+ public function mark_provided( string $field ): void {
+ $this->provided_fields[ $field ] = true;
+ }
+
+ /**
+ * Check whether a field was explicitly provided in the input.
+ *
+ * @param string $field The field name.
+ * @return bool
+ */
+ public function was_provided( string $field ): bool {
+ return isset( $this->provided_fields[ $field ] );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Interfaces/ObjectWithId.php b/plugins/woocommerce/src/Api/Interfaces/ObjectWithId.php
new file mode 100644
index 00000000000..18254488856
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Interfaces/ObjectWithId.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Interfaces;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Interface trait for objects that have a numeric ID.
+ */
+#[Description( 'An object with a numeric ID.' )]
+trait ObjectWithId {
+ /**
+ * The unique numeric identifier.
+ *
+ * @var int
+ */
+ #[Description( 'The unique numeric identifier.' )]
+ public int $id;
+}
diff --git a/plugins/woocommerce/src/Api/Interfaces/Product.php b/plugins/woocommerce/src/Api/Interfaces/Product.php
new file mode 100644
index 00000000000..a4a3f1443c8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Interfaces/Product.php
@@ -0,0 +1,212 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Interfaces;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Deprecated;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Scalars\DateTime;
+use Automattic\WooCommerce\Api\Types\Products\ProductDimensions;
+use Automattic\WooCommerce\Api\Types\Products\ProductAttribute;
+use Automattic\WooCommerce\Api\Types\Products\ProductImage;
+use Automattic\WooCommerce\Api\Types\Products\ProductReview;
+
+/**
+ * Interface trait for WooCommerce products.
+ *
+ * Defines the common fields shared by all product types.
+ */
+#[Name( 'Product' )]
+#[Description( 'A WooCommerce product.' )]
+trait Product {
+ use ObjectWithId;
+
+ /**
+ * The product name.
+ *
+ * @var string
+ */
+ #[Description( 'The product name.' )]
+ public string $name;
+
+ /**
+ * The product slug.
+ *
+ * @var string
+ */
+ #[Description( 'The product slug.' )]
+ public string $slug;
+
+ /**
+ * The product SKU.
+ *
+ * @var ?string
+ */
+ #[Description( 'The product SKU.' )]
+ public ?string $sku;
+
+ /**
+ * The full product description.
+ *
+ * @var string
+ */
+ #[Description( 'The full product description.' )]
+ public string $description;
+
+ /**
+ * The short product description.
+ *
+ * @var string
+ */
+ #[Deprecated( 'Use description instead.' )]
+ #[Description( 'The short product description.' )]
+ public string $short_description;
+
+ /**
+ * The product status.
+ *
+ * @var ProductStatus
+ */
+ #[Description( 'The product status.' )]
+ public ProductStatus $status;
+
+ /**
+ * The raw status as stored in WordPress. Useful when status is OTHER.
+ *
+ * @var string
+ */
+ #[Description( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).' )]
+ public string $raw_status;
+
+ /**
+ * The product type.
+ *
+ * @var ProductType
+ */
+ #[Description( 'The product type.' )]
+ public ProductType $product_type;
+
+ /**
+ * The raw product type as stored in WooCommerce. Useful when product_type is OTHER.
+ *
+ * @var string
+ */
+ #[Description( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).' )]
+ public string $raw_product_type;
+
+ /**
+ * The regular price of the product. Null when not set.
+ *
+ * @var ?string
+ */
+ #[Description( 'The regular price of the product. Null when not set.' )]
+ #[Parameter( name: 'formatted', type: 'bool', default: true, description: 'Whether to apply currency formatting.' )]
+ public ?string $regular_price;
+
+ /**
+ * The sale price of the product.
+ *
+ * @var ?string
+ */
+ #[Description( 'The sale price of the product.' )]
+ #[Parameter( name: 'formatted', type: 'bool', default: true )]
+ #[ParameterDescription( name: 'formatted', description: 'When true, returns price with currency symbol.' )]
+ public ?string $sale_price;
+
+ /**
+ * The stock status of the product.
+ *
+ * @var StockStatus
+ */
+ #[Description( 'The stock status of the product.' )]
+ public StockStatus $stock_status;
+
+ /**
+ * The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER.
+ *
+ * @var string
+ */
+ #[Description( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).' )]
+ public string $raw_stock_status;
+
+ /**
+ * The number of items in stock.
+ *
+ * @var ?int
+ */
+ #[Description( 'The number of items in stock.' )]
+ public ?int $stock_quantity;
+
+ /**
+ * The product dimensions.
+ *
+ * @var ?ProductDimensions
+ */
+ #[Description( 'The product dimensions.' )]
+ public ?ProductDimensions $dimensions;
+
+ /**
+ * The product images.
+ *
+ * @var ProductImage[]
+ */
+ #[Description( 'The product images.' )]
+ #[ArrayOf( ProductImage::class )]
+ public array $images;
+
+ /**
+ * The product attributes.
+ *
+ * @var ProductAttribute[]
+ */
+ #[Description( 'The product attributes.' )]
+ #[ArrayOf( ProductAttribute::class )]
+ public array $attributes;
+
+ /**
+ * Customer reviews for this product.
+ *
+ * @var Connection
+ */
+ #[Description( 'Customer reviews for this product.' )]
+ #[ConnectionOf( ProductReview::class )]
+ public Connection $reviews;
+
+ /**
+ * The date the product was created.
+ *
+ * @var ?string
+ */
+ #[Description( 'The date the product was created.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_created;
+
+ /**
+ * The date the product was last modified.
+ *
+ * @var ?string
+ */
+ #[Description( 'The date the product was last modified.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_modified;
+
+ /**
+ * Internal notes (ignored in schema).
+ *
+ * @var ?string
+ */
+ #[Ignore]
+ public ?string $internal_notes;
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Coupons/CreateCoupon.php b/plugins/woocommerce/src/Api/Mutations/Coupons/CreateCoupon.php
new file mode 100644
index 00000000000..d5f60c351db
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Coupons/CreateCoupon.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+
+/**
+ * Mutation to create a new coupon.
+ */
+#[Description( 'Create a new coupon.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class CreateCoupon {
+ /**
+ * Execute the mutation.
+ *
+ * @param CreateCouponInput $input The coupon creation data.
+ * @return Coupon
+ */
+ public function execute(
+ #[Description( 'Data for the new coupon.' )]
+ CreateCouponInput $input,
+ ): Coupon {
+ $wc_coupon = new \WC_Coupon();
+ $wc_coupon->set_code( $input->code );
+
+ foreach ( array( 'description', 'amount', 'date_expires', 'individual_use', 'product_ids', 'excluded_product_ids', 'usage_limit', 'usage_limit_per_user', 'limit_usage_to_x_items', 'free_shipping', 'product_categories', 'excluded_product_categories', 'exclude_sale_items', 'minimum_amount', 'maximum_amount', 'email_restrictions' ) as $field ) {
+ if ( null !== $input->$field ) {
+ $wc_coupon->{"set_{$field}"}( $input->$field );
+ }
+ }
+
+ if ( null !== $input->discount_type ) {
+ $wc_coupon->set_discount_type( $input->discount_type->value );
+ }
+ if ( null !== $input->status ) {
+ $wc_coupon->set_status( $input->status->value );
+ }
+
+ $wc_coupon->save();
+
+ return CouponMapper::from_wc_coupon( $wc_coupon );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Coupons/DeleteCoupon.php b/plugins/woocommerce/src/Api/Mutations/Coupons/DeleteCoupon.php
new file mode 100644
index 00000000000..fbe77a0189d
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Coupons/DeleteCoupon.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Types\Coupons\DeleteCouponResult;
+
+/**
+ * Mutation to delete a coupon.
+ */
+#[Description( 'Delete a coupon.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class DeleteCoupon {
+ /**
+ * Execute the mutation.
+ *
+ * @param int $id The coupon ID.
+ * @param bool $force Whether to permanently delete.
+ * @return DeleteCouponResult
+ * @throws ApiException When the coupon is not found.
+ */
+ public function execute(
+ #[Description( 'The ID of the coupon to delete.' )]
+ int $id,
+ #[Description( 'Whether to permanently delete the coupon (bypass trash).' )]
+ bool $force = false,
+ ): DeleteCouponResult {
+ $wc_coupon = new \WC_Coupon( $id );
+
+ if ( ! $wc_coupon->get_id() ) {
+ throw new ApiException( 'Coupon not found.', 'NOT_FOUND', status_code: 404 );
+ }
+
+ // Capture the raw return value. A `(bool)` cast would coerce
+ // filter-originated `WP_Error` objects to `true`, reporting failure
+ // as success; we need to detect that case explicitly and surface
+ // the underlying error instead.
+ $deleted = $wc_coupon->delete( $force );
+
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ if ( $deleted instanceof \WP_Error ) {
+ throw new ApiException(
+ $deleted->get_error_message(),
+ 'INTERNAL_ERROR',
+ status_code: 500,
+ );
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+ $result = new DeleteCouponResult();
+ $result->id = $id;
+ $result->deleted = true === $deleted;
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Coupons/UpdateCoupon.php b/plugins/woocommerce/src/Api/Mutations/Coupons/UpdateCoupon.php
new file mode 100644
index 00000000000..6ee4f522f28
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Coupons/UpdateCoupon.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Coupons;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+
+/**
+ * Mutation to update an existing coupon.
+ */
+#[Description( 'Update an existing coupon.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class UpdateCoupon {
+ /**
+ * Execute the mutation.
+ *
+ * @param UpdateCouponInput $input The fields to update.
+ * @return Coupon
+ * @throws ApiException When the coupon is not found.
+ */
+ public function execute(
+ #[Description( 'The fields to update.' )]
+ UpdateCouponInput $input,
+ ): Coupon {
+ $wc_coupon = new \WC_Coupon( $input->id );
+
+ if ( ! $wc_coupon->get_id() ) {
+ throw new ApiException( 'Coupon not found.', 'NOT_FOUND', status_code: 404 );
+ }
+
+ foreach ( array( 'code', 'description', 'amount', 'date_expires', 'individual_use', 'product_ids', 'excluded_product_ids', 'usage_limit', 'usage_limit_per_user', 'limit_usage_to_x_items', 'free_shipping', 'product_categories', 'excluded_product_categories', 'exclude_sale_items', 'minimum_amount', 'maximum_amount', 'email_restrictions' ) as $field ) {
+ if ( $input->was_provided( $field ) ) {
+ $wc_coupon->{"set_{$field}"}( $input->$field );
+ }
+ }
+
+ // Nullable enums: only invoke the setter when the client supplied a
+ // non-null value. An explicit null means "ignore this field" here —
+ // WC_Coupon's enum setters don't accept null and would fall back to
+ // their defaults (e.g. 'fixed_cart' for discount_type), silently
+ // overwriting whatever is already on the coupon.
+ if ( $input->was_provided( 'discount_type' ) && null !== $input->discount_type ) {
+ $wc_coupon->set_discount_type( $input->discount_type->value );
+ }
+ if ( $input->was_provided( 'status' ) && null !== $input->status ) {
+ $wc_coupon->set_status( $input->status->value );
+ }
+
+ $wc_coupon->save();
+
+ return CouponMapper::from_wc_coupon( $wc_coupon );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Products/CreateProduct.php b/plugins/woocommerce/src/Api/Mutations/Products/CreateProduct.php
new file mode 100644
index 00000000000..13cb0574277
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Products/CreateProduct.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Traits\RequiresManageWoocommerce;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+use Automattic\WooCommerce\Api\Utils\Products\ProductRepository;
+
+/**
+ * Mutation to create a new product.
+ *
+ * Demonstrates: DI via init(), inherited capability (trait), ApiException with extensions.
+ */
+#[Description( 'Create a new product.' )]
+#[RequiredCapability( 'edit_products' )]
+class CreateProduct {
+ use RequiresManageWoocommerce;
+
+ /**
+ * The product repository.
+ *
+ * @var ProductRepository
+ */
+ private ProductRepository $repository;
+
+ /**
+ * Inject dependencies via the DI container.
+ *
+ * @internal
+ *
+ * @param ProductRepository $repository The product repository.
+ */
+ final public function init( ProductRepository $repository ): void {
+ $this->repository = $repository;
+ }
+
+ /**
+ * Execute the mutation.
+ *
+ * @param CreateProductInput $input The product creation data.
+ * @return object
+ * @throws ApiException When validation fails.
+ */
+ #[ReturnType( Product::class )]
+ public function execute(
+ #[Description( 'Data for the new product.' )]
+ CreateProductInput $input,
+ ): object {
+ // Best-effort duplicate-name check. There is an inherent TOCTOU race
+ // here: two nearly-simultaneous requests with the same name can both
+ // pass this check and both succeed in creating the product, because
+ // wp_posts.post_title is not a unique column in the schema and WP
+ // offers no portable atomic "reserve name" primitive. Locking via
+ // wp_cache_add() would help only on sites with a persistent object
+ // cache (Redis/Memcached), so we do not rely on it here. If strict
+ // uniqueness is ever required, callers should enforce it at a
+ // higher layer (e.g. a mutex around the REST handler) rather than
+ // assume the API guarantees it.
+ $existing = new \WP_Query(
+ array(
+ 'post_type' => 'product',
+ 'title' => $input->name,
+ 'post_status' => array( 'publish', 'draft', 'pending', 'private' ),
+ 'fields' => 'ids',
+ )
+ );
+
+ if ( $existing->found_posts > 0 ) {
+ throw new ApiException(
+ 'A product with this name already exists.',
+ 'VALIDATION_ERROR',
+ array( 'field' => 'name' ),
+ 422,
+ );
+ }
+
+ $wc_product = new \WC_Product();
+ $wc_product->set_name( $input->name );
+
+ foreach ( array( 'slug', 'sku', 'description', 'short_description', 'manage_stock', 'stock_quantity' ) as $field ) {
+ if ( null !== $input->$field ) {
+ $wc_product->{"set_{$field}"}( $input->$field );
+ }
+ }
+
+ foreach ( array( 'regular_price', 'sale_price' ) as $field ) {
+ if ( null !== $input->$field ) {
+ $wc_product->{"set_{$field}"}( (string) $input->$field );
+ }
+ }
+
+ if ( null !== $input->status ) {
+ $wc_product->set_status( $input->status->value );
+ }
+
+ if ( null !== $input->dimensions ) {
+ foreach ( array( 'length', 'width', 'height', 'weight' ) as $field ) {
+ if ( null !== $input->dimensions->$field ) {
+ $wc_product->{"set_{$field}"}( (string) $input->dimensions->$field );
+ }
+ }
+ }
+
+ $this->repository->save( $wc_product );
+
+ return ProductMapper::from_wc_product( $wc_product );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Products/DeleteProduct.php b/plugins/woocommerce/src/Api/Mutations/Products/DeleteProduct.php
new file mode 100644
index 00000000000..270d238c2d3
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Products/DeleteProduct.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Mutation to delete a product.
+ *
+ * Demonstrates: mutation returning bool.
+ */
+#[Description( 'Delete a product.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class DeleteProduct {
+ /**
+ * Execute the mutation.
+ *
+ * @param int $id The product ID.
+ * @param bool $force Whether to permanently delete (bypass trash).
+ * @return bool Whether the product was deleted.
+ * @throws ApiException When the product is not found.
+ */
+ public function execute(
+ #[Description( 'The ID of the product to delete.' )]
+ int $id,
+ #[Description( 'Whether to permanently delete the product (bypass trash).' )]
+ bool $force = false,
+ ): bool {
+ $wc_product = wc_get_product( $id );
+
+ if ( ! $wc_product instanceof \WC_Product ) {
+ throw new ApiException( 'Product not found.', 'NOT_FOUND', status_code: 404 );
+ }
+
+ // Capture the raw return value. A `(bool)` cast would coerce
+ // filter-originated `WP_Error` objects to `true`, reporting failure
+ // as success; we need to detect that case explicitly and surface
+ // the underlying error instead.
+ $deleted = $wc_product->delete( $force );
+
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ if ( $deleted instanceof \WP_Error ) {
+ throw new ApiException(
+ $deleted->get_error_message(),
+ 'INTERNAL_ERROR',
+ status_code: 500,
+ );
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+
+ return true === $deleted;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Mutations/Products/UpdateProduct.php b/plugins/woocommerce/src/Api/Mutations/Products/UpdateProduct.php
new file mode 100644
index 00000000000..0ae5880949d
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Mutations/Products/UpdateProduct.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Mutations\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+
+/**
+ * Mutation to update an existing product.
+ */
+#[Description( 'Update an existing product.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+class UpdateProduct {
+ /**
+ * Execute the mutation.
+ *
+ * @param UpdateProductInput $input The fields to update.
+ * @return object
+ * @throws ApiException When the product is not found.
+ */
+ #[ReturnType( Product::class )]
+ public function execute(
+ #[Description( 'The fields to update.' )]
+ UpdateProductInput $input,
+ ): object {
+ $wc_product = wc_get_product( $input->id );
+
+ if ( ! $wc_product instanceof \WC_Product ) {
+ throw new ApiException( 'Product not found.', 'NOT_FOUND', status_code: 404 );
+ }
+
+ foreach ( array( 'name', 'slug', 'sku', 'description', 'short_description', 'manage_stock', 'stock_quantity' ) as $field ) {
+ if ( $input->was_provided( $field ) ) {
+ $wc_product->{"set_{$field}"}( $input->$field );
+ }
+ }
+
+ foreach ( array( 'regular_price', 'sale_price' ) as $field ) {
+ if ( $input->was_provided( $field ) ) {
+ $wc_product->{"set_{$field}"}( null !== $input->$field ? (string) $input->$field : '' );
+ }
+ }
+
+ // Nullable enum: only invoke the setter when the client supplied a
+ // non-null value. An explicit null means "ignore this field" here —
+ // WC_Product's set_status doesn't accept null and would fall back
+ // to a default, silently overwriting whatever is already on the
+ // product.
+ if ( $input->was_provided( 'status' ) && null !== $input->status ) {
+ $wc_product->set_status( $input->status->value );
+ }
+
+ if ( $input->was_provided( 'dimensions' ) ) {
+ foreach ( array( 'length', 'width', 'height', 'weight' ) as $field ) {
+ if ( $input->dimensions->was_provided( $field ) ) {
+ $wc_product->{"set_{$field}"}( null !== $input->dimensions->$field ? (string) $input->dimensions->$field : '' );
+ }
+ }
+ }
+
+ $wc_product->save();
+
+ return ProductMapper::from_wc_product( $wc_product );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/Connection.php b/plugins/woocommerce/src/Api/Pagination/Connection.php
new file mode 100644
index 00000000000..de81735d929
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/Connection.php
@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+/**
+ * Represents a Relay-style paginated connection.
+ */
+class Connection {
+ /**
+ * Connection edges wrapping each node with its cursor.
+ *
+ * @var Edge[]
+ */
+ public array $edges;
+
+ /**
+ * The raw nodes without cursor wrappers.
+ *
+ * @var object[]
+ */
+ public array $nodes;
+
+ public PageInfo $page_info;
+
+ public int $total_count;
+
+ /**
+ * Whether this connection has already been sliced.
+ *
+ * When true, subsequent calls to slice() return $this immediately,
+ * preventing double-slicing when both the command class and the
+ * auto-generated resolver call slice().
+ *
+ * @var bool
+ */
+ private bool $sliced = false;
+
+ /**
+ * Create a pre-sliced connection for the performance path.
+ *
+ * Use this when the DB query already applied pagination limits,
+ * so no further slicing is needed.
+ *
+ * @param Edge[] $edges The already-paginated edges.
+ * @param PageInfo $page_info The pagination info.
+ * @param int $total_count The total count before pagination.
+ * @return self A Connection marked as already sliced.
+ */
+ public static function pre_sliced( array $edges, PageInfo $page_info, int $total_count ): self {
+ $connection = new self();
+ $connection->edges = $edges;
+ $connection->nodes = array_map( fn( Edge $e ) => $e->node, $edges );
+ $connection->page_info = $page_info;
+ $connection->total_count = $total_count;
+ $connection->sliced = true;
+
+ return $connection;
+ }
+
+ /**
+ * Return a new Connection sliced according to the given pagination args.
+ *
+ * Applies the Relay cursor-based pagination algorithm: first narrow by
+ * after/before cursors, then take first N or last N from the remainder.
+ *
+ * @param array $args Pagination arguments with keys: first, last, after, before.
+ * @return self A new Connection with sliced edges/nodes and updated page_info.
+ */
+ public function slice( array $args ): self {
+ if ( $this->sliced ) {
+ return $this;
+ }
+
+ // Enforce the same 0..MAX_PAGE_SIZE bounds that PaginationParams
+ // applies to root queries. Without this, nested connection fields
+ // (e.g. `variations(first: 1000)`) would slip past the cap because
+ // the generated resolver passes raw GraphQL args straight in.
+ PaginationParams::validate_args( $args );
+
+ $first = $args['first'] ?? null;
+ $last = $args['last'] ?? null;
+ $after = $args['after'] ?? null;
+ $before = $args['before'] ?? null;
+
+ // No pagination requested — return as-is.
+ if ( null === $first && null === $last && null === $after && null === $before ) {
+ return $this;
+ }
+
+ $edges = $this->edges;
+
+ // Narrow by "after" cursor.
+ if ( null !== $after ) {
+ $found = false;
+ foreach ( $edges as $i => $edge ) {
+ if ( $edge->cursor === $after ) {
+ $edges = array_slice( $edges, $i + 1 );
+ $found = true;
+ break;
+ }
+ }
+ if ( ! $found ) {
+ $edges = array();
+ }
+ }
+
+ // Narrow by "before" cursor.
+ if ( null !== $before ) {
+ $filtered = array();
+ foreach ( $edges as $edge ) {
+ if ( $edge->cursor === $before ) {
+ break;
+ }
+ $filtered[] = $edge;
+ }
+ $edges = $filtered;
+ }
+
+ $total_after_cursors = count( $edges );
+
+ // Apply first/last.
+ if ( null !== $first && $first >= 0 ) {
+ $edges = array_slice( $edges, 0, $first );
+ }
+ if ( null !== $last && $last >= 0 ) {
+ $edges = array_slice( $edges, max( 0, count( $edges ) - $last ) );
+ }
+
+ // Build the sliced connection.
+ $connection = new self();
+ $connection->edges = array_values( $edges );
+ $connection->nodes = array_map( fn( Edge $e ) => $e->node, $edges );
+ $connection->total_count = $this->total_count;
+ $connection->sliced = true;
+
+ $page_info = new PageInfo();
+ $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
+ $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+ $page_info->has_next_page = null !== $first ? count( $edges ) < $total_after_cursors : $this->page_info->has_next_page;
+ $page_info->has_previous_page = null !== $last ? count( $edges ) < $total_after_cursors : ( null !== $after );
+ $connection->page_info = $page_info;
+
+ return $connection;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/Edge.php b/plugins/woocommerce/src/Api/Pagination/Edge.php
new file mode 100644
index 00000000000..8141f707aa3
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/Edge.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+/**
+ * Represents an edge in a Relay-style connection.
+ */
+class Edge {
+ public string $cursor;
+
+ public object $node;
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/IdCursorFilter.php b/plugins/woocommerce/src/Api/Pagination/IdCursorFilter.php
new file mode 100644
index 00000000000..c543a1e918d
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/IdCursorFilter.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+use Automattic\WooCommerce\Api\ApiException;
+
+/**
+ * WP_Query ID-cursor pagination helper.
+ *
+ * Implements cursor-based pagination on the posts ID column by hooking
+ * `posts_where` and reading two custom query vars:
+ *
+ * - `wc_api_after_id` — emit `AND ID > X` in the SQL WHERE clause.
+ * - `wc_api_before_id` — emit `AND ID < X`.
+ *
+ * Resolvers set whichever of those vars they need on their WP_Query args
+ * and call {@see self::ensure_registered()} once before running the query.
+ * The filter registers itself lazily on first use and short-circuits for
+ * any query that doesn't set these vars, so it's safe to leave in place
+ * for the rest of the request.
+ */
+class IdCursorFilter {
+
+ /**
+ * Query var for the exclusive lower-bound ID (`ID > X`).
+ */
+ public const AFTER_ID = 'wc_api_after_id';
+
+ /**
+ * Query var for the exclusive upper-bound ID (`ID < X`).
+ */
+ public const BEFORE_ID = 'wc_api_before_id';
+
+ /**
+ * Whether the posts_where hook is currently registered for this request.
+ *
+ * @var bool
+ */
+ private static bool $registered = false;
+
+ /**
+ * Register the posts_where filter on first call; no-op thereafter.
+ *
+ * The filter is a no-op for queries that don't set the cursor query
+ * vars, so leaving it registered for the remainder of the request is
+ * harmless — and it means resolvers never need to clean up after
+ * themselves, which is how the previous add/remove dance leaked.
+ */
+ public static function ensure_registered(): void {
+ if ( self::$registered ) {
+ return;
+ }
+ add_filter( 'posts_where', array( self::class, 'apply' ), 10, 2 );
+ self::$registered = true;
+ }
+
+ /**
+ * Filter callback for `posts_where`. Appends cursor conditions when the
+ * corresponding query vars are set on the WP_Query; returns the input
+ * clause unchanged otherwise.
+ *
+ * @param string $where SQL WHERE clause being built.
+ * @param \WP_Query $query The WP_Query being prepared.
+ * @return string The modified WHERE clause.
+ */
+ public static function apply( string $where, \WP_Query $query ): string {
+ $after = (int) $query->get( self::AFTER_ID );
+ $before = (int) $query->get( self::BEFORE_ID );
+
+ if ( $after <= 0 && $before <= 0 ) {
+ return $where;
+ }
+
+ global $wpdb;
+ if ( $after > 0 ) {
+ $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $after );
+ }
+ if ( $before > 0 ) {
+ $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $before );
+ }
+ return $where;
+ }
+
+ /**
+ * Decode a base64-encoded ID cursor into a positive integer.
+ *
+ * Resolvers encode cursors via `base64_encode( (string) $id )` on the
+ * way out; this is the symmetric decode. `base64_decode(..., true)`
+ * returns false for malformed input, which `(int)` casts to 0 and
+ * {@see self::apply()} would silently treat as "no cursor" — leaving
+ * clients with unfiltered results instead of a clear error. Validate
+ * explicitly and throw INVALID_ARGUMENT → HTTP 400 on any bad input.
+ *
+ * @param string $cursor The client-supplied cursor string.
+ * @param string $name Which cursor argument (`after` / `before`), for error messages.
+ * @return int The decoded positive integer ID.
+ * @throws ApiException When the cursor isn't a valid base64-encoded positive integer.
+ */
+ public static function decode_id_cursor( string $cursor, string $name ): int {
+ $raw = base64_decode( $cursor, true );
+ if ( false === $raw || ! ctype_digit( $raw ) || (int) $raw <= 0 ) {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ throw new ApiException(
+ sprintf( 'Invalid `%s` cursor.', $name ),
+ 'INVALID_ARGUMENT',
+ status_code: 400,
+ );
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+ return (int) $raw;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/PageInfo.php b/plugins/woocommerce/src/Api/Pagination/PageInfo.php
new file mode 100644
index 00000000000..e69419aa897
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/PageInfo.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+/**
+ * Pagination metadata for a connection.
+ */
+class PageInfo {
+ public bool $has_next_page;
+
+ public bool $has_previous_page;
+
+ public ?string $start_cursor;
+
+ public ?string $end_cursor;
+}
diff --git a/plugins/woocommerce/src/Api/Pagination/PaginationParams.php b/plugins/woocommerce/src/Api/Pagination/PaginationParams.php
new file mode 100644
index 00000000000..6391e5ce6c9
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Pagination/PaginationParams.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Pagination;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+
+/**
+ * Standard pagination parameters for connection queries.
+ *
+ * Because this class carries #[Unroll], whenever it is used as an execute()
+ * parameter the builder expands its properties into individual GraphQL arguments.
+ */
+#[Unroll]
+class PaginationParams {
+ /**
+ * Maximum number of items a client may request in a single page.
+ *
+ * Requests with `first` or `last` above this value are rejected with an
+ * INVALID_ARGUMENT error, matching the behavior of common GraphQL APIs
+ * (e.g. GitHub's 100-item cap).
+ */
+ public const MAX_PAGE_SIZE = 100;
+
+ /**
+ * Page size used when neither `first` nor `last` is provided.
+ */
+ public const DEFAULT_PAGE_SIZE = 100;
+
+ /**
+ * Constructor.
+ *
+ * @param ?int $first Return the first N results.
+ * @param ?int $last Return the last N results.
+ * @param ?string $after Return results after this cursor.
+ * @param ?string $before Return results before this cursor.
+ *
+ * @throws \InvalidArgumentException When `first` or `last` is negative or exceeds MAX_PAGE_SIZE.
+ */
+ public function __construct(
+ #[Description( 'Return the first N results. Must be between 0 and ' . self::MAX_PAGE_SIZE . '.' )]
+ public readonly ?int $first = null,
+ #[Description( 'Return the last N results. Must be between 0 and ' . self::MAX_PAGE_SIZE . '.' )]
+ public readonly ?int $last = null,
+ #[Description( 'Return results after this cursor.' )]
+ public readonly ?string $after = null,
+ #[Description( 'Return results before this cursor.' )]
+ public readonly ?string $before = null,
+ ) {
+ self::validate_limit( 'first', $first );
+ self::validate_limit( 'last', $last );
+ }
+
+ /**
+ * The page size to use when no explicit `first` or `last` is provided.
+ *
+ * Exposed as a method (not just the constant) so the default can become
+ * configurable — e.g. via a filter or store option — without requiring
+ * call-site changes.
+ *
+ * @return int
+ */
+ public static function get_default_page_size(): int {
+ return self::DEFAULT_PAGE_SIZE;
+ }
+
+ /**
+ * Validate pagination limits on a raw args array without constructing a
+ * full PaginationParams instance.
+ *
+ * Intended for call sites that take raw GraphQL args (like nested
+ * connection resolvers) and forward them to Connection::slice(). The
+ * constructor already runs the same checks for root queries that build
+ * a PaginationParams via #[Unroll], so this keeps both paths in sync.
+ *
+ * @param array $args Raw args with optional `first` / `last` keys.
+ *
+ * @throws \InvalidArgumentException When either limit is negative or above MAX_PAGE_SIZE.
+ */
+ public static function validate_args( array $args ): void {
+ self::validate_limit( 'first', $args['first'] ?? null );
+ self::validate_limit( 'last', $args['last'] ?? null );
+ }
+
+ /**
+ * Validate a `first` / `last` argument.
+ *
+ * @param string $name The argument name, for the error message.
+ * @param ?int $value The value to validate.
+ *
+ * @throws \InvalidArgumentException When the value is out of range.
+ */
+ private static function validate_limit( string $name, ?int $value ): void {
+ if ( null === $value ) {
+ return;
+ }
+
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output; serialized as JSON in the GraphQL error response.
+ if ( $value < 0 ) {
+ throw new \InvalidArgumentException(
+ sprintf( 'Argument `%s` must be zero or greater.', $name )
+ );
+ }
+
+ if ( $value > self::MAX_PAGE_SIZE ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'Argument `%s` exceeds the maximum page size of %d.',
+ $name,
+ self::MAX_PAGE_SIZE
+ )
+ );
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Coupons/GetCoupon.php b/plugins/woocommerce/src/Api/Queries/Coupons/GetCoupon.php
new file mode 100644
index 00000000000..4f011350ec5
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Coupons/GetCoupon.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+
+#[Name( 'coupon' )]
+#[Description( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.' )]
+/**
+ * Query to retrieve a single coupon.
+ */
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class GetCoupon {
+ /**
+ * Retrieve a coupon by ID or code.
+ *
+ * @param ?int $id The coupon ID.
+ * @param ?string $code The coupon code.
+ * @return ?Coupon
+ * @throws \InvalidArgumentException When neither or both arguments are provided.
+ */
+ public function execute(
+ #[Description( 'The ID of the coupon to retrieve.' )]
+ ?int $id = null,
+ #[Description( 'The coupon code to look up.' )]
+ ?string $code = null,
+ ): ?Coupon {
+ if ( ( null === $id ) === ( null === $code ) ) {
+ throw new \InvalidArgumentException( 'Exactly one of "id" or "code" must be provided.' );
+ }
+
+ $wc_coupon = new \WC_Coupon( $id ?? $code );
+
+ if ( ! $wc_coupon->get_id() ) {
+ return null;
+ }
+
+ return CouponMapper::from_wc_coupon( $wc_coupon );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Coupons/ListCoupons.php b/plugins/woocommerce/src/Api/Queries/Coupons/ListCoupons.php
new file mode 100644
index 00000000000..c1f6cc5a87e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Coupons/ListCoupons.php
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+use Automattic\WooCommerce\Api\Utils\Coupons\CouponMapper;
+
+#[Name( 'coupons' )]
+#[Description( 'List coupons with cursor-based pagination.' )]
+/**
+ * Query to list coupons with cursor-based pagination.
+ */
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class ListCoupons {
+ /**
+ * List coupons with optional filtering and pagination.
+ *
+ * @param PaginationParams $pagination The pagination parameters.
+ * @param ?CouponStatus $status Optional status filter.
+ * @return Connection
+ */
+ #[ConnectionOf( Coupon::class )]
+ public function execute(
+ PaginationParams $pagination,
+ #[Description( 'Filter by coupon status.' )]
+ ?CouponStatus $status = null,
+ ): Connection {
+ $first = $pagination->first;
+ $last = $pagination->last;
+ $after = $pagination->after;
+ $before = $pagination->before;
+ $limit = $first ?? $last ?? PaginationParams::get_default_page_size();
+
+ // Use WP_Query for the count and a filtered query for cursor-based
+ // pagination. We only need `found_posts` (which comes from the
+ // SQL_CALC_FOUND_ROWS query WP runs alongside the main SELECT), so
+ // the main SELECT fetches only one row — posts_per_page => -1 would
+ // materialize every ID just to throw it away.
+ $count_args = array(
+ 'post_type' => 'shop_coupon',
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'post_status' => $status?->value ?? 'any',
+ );
+ $count_query = new \WP_Query( $count_args );
+ $total_count = $count_query->found_posts;
+
+ // Fetch posts with cursor filtering via post__in or meta_query workaround.
+ // For simplicity, we use direct ID-based filtering.
+ $posts_query_args = array(
+ 'post_type' => 'shop_coupon',
+ 'posts_per_page' => $limit + 1,
+ 'orderby' => 'ID',
+ 'order' => null !== $last ? 'DESC' : 'ASC',
+ 'post_status' => $status?->value ?? 'any',
+ );
+
+ if ( null !== $after ) {
+ $posts_query_args[ IdCursorFilter::AFTER_ID ] = IdCursorFilter::decode_id_cursor( $after, 'after' );
+ }
+ if ( null !== $before ) {
+ $posts_query_args[ IdCursorFilter::BEFORE_ID ] = IdCursorFilter::decode_id_cursor( $before, 'before' );
+ }
+ IdCursorFilter::ensure_registered();
+
+ $query = new \WP_Query( $posts_query_args );
+ $posts = $query->posts;
+
+ // Determine pagination.
+ $has_extra = count( $posts ) > $limit;
+ if ( $has_extra ) {
+ $posts = array_slice( $posts, 0, $limit );
+ }
+
+ // If we fetched in DESC order for $last, reverse to get ascending order.
+ if ( null !== $last ) {
+ $posts = array_reverse( $posts );
+ }
+
+ // Build edges and nodes.
+ $edges = array();
+ $nodes = array();
+ foreach ( $posts as $post ) {
+ $wc_coupon = new \WC_Coupon( $post->ID );
+ $coupon = CouponMapper::from_wc_coupon( $wc_coupon );
+
+ $edge = new Edge();
+ $edge->cursor = base64_encode( (string) $coupon->id );
+ $edge->node = $coupon;
+
+ $edges[] = $edge;
+ $nodes[] = $coupon;
+ }
+
+ $page_info = new PageInfo();
+ // Relay semantics for backward pagination (`last`, `before`): the
+ // returned window ends just before `$before`, so items after the
+ // window exist whenever `$before` was supplied — not whenever
+ // `$after` was. `has_previous_page` in the backward case is driven
+ // by the "did we fetch limit+1?" sentinel (`$has_extra`).
+ $page_info->has_next_page = null !== $last ? ( null !== $before ) : $has_extra;
+ $page_info->has_previous_page = null !== $last ? $has_extra : ( null !== $after );
+ $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
+ $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+ $connection = new Connection();
+ $connection->edges = $edges;
+ $connection->nodes = $nodes;
+ $connection->page_info = $page_info;
+ $connection->total_count = $total_count;
+
+ return $connection;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php b/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
new file mode 100644
index 00000000000..e6b4d35f804
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\AuthorizationException;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+
+/**
+ * Query to retrieve a single product by ID.
+ *
+ * Demonstrates: authorize(), $_query_info, AuthorizationException.
+ *
+ * Authorization logic: admins (manage_woocommerce) can read any product,
+ * non-admin users can only read their own products.
+ */
+#[Name( 'product' )]
+#[Description( 'Retrieve a single product by ID.' )]
+#[RequiredCapability( 'read_product' )]
+class GetProduct {
+ /**
+ * Authorize access to a specific product.
+ *
+ * Admins can read any product. Non-admin users can only read products
+ * they authored themselves.
+ *
+ * Every inaccessible case throws `AuthorizationException('Product not
+ * found.')` — whether the ID doesn't exist, points at a non-product
+ * post type, or points at a product the caller doesn't own. This
+ * prevents callers from enumerating product IDs vs non-product post
+ * IDs via the response they get back (which would otherwise be "not
+ * found" vs "no permission").
+ *
+ * @param int $id The product ID.
+ * @param bool $_preauthorized Whether the declared capability check passed.
+ * @return bool Whether the current user can read this product.
+ * @throws AuthorizationException When the product is not accessible.
+ */
+ public function authorize( int $id, bool $_preauthorized ): bool {
+ // Reject non-positive IDs up front. `get_post( 0 )` inside a
+ // WordPress loop returns `$GLOBALS['post']` (not null), so a bare
+ // `get_post( $id )` below would accidentally operate on whatever
+ // global post was set upstream of this request.
+ if ( $id <= 0 ) {
+ throw new AuthorizationException( 'Product not found.' );
+ }
+
+ $post = get_post( $id );
+
+ if ( ! $post || 'product' !== $post->post_type ) {
+ throw new AuthorizationException( 'Product not found.' );
+ }
+
+ // Honor the declared #[RequiredCapability] (read_product).
+ if ( $_preauthorized ) {
+ return true;
+ }
+
+ // `manage_woocommerce` is the canonical "admin sees everything"
+ // capability in WooCommerce. The declared #[RequiredCapability]
+ // pre-authorizes on `read_product` (the read-level post-type cap,
+ // which is what the schema advertises), but an admin whose cap set
+ // grants `manage_woocommerce` without `read_product` would
+ // otherwise fall through to the ownership check and get "Product
+ // not found" for any product they don't own — contrary to the
+ // documented admin-can-see-everything contract.
+ if ( current_user_can( 'manage_woocommerce' ) ) {
+ return true;
+ }
+
+ // Non-admin users can only read their own products. Throw the same
+ // "not found" exception rather than returning false — a distinct
+ // "you don't have permission" error here would tell the caller
+ // that the ID is a product (just not theirs), leaking the
+ // product-ID space vs the rest of the post-ID space.
+ //
+ // Reject guest users explicitly: get_current_user_id() returns 0
+ // for unauthenticated callers, and products created via WP-CLI,
+ // imports, or programmatic inserts without an author can have
+ // post_author = 0 — a bare `!==` check would mis-grant access to
+ // anonymous callers for those products.
+ $current_user_id = get_current_user_id();
+ if ( 0 === $current_user_id || $current_user_id !== (int) $post->post_author ) {
+ throw new AuthorizationException( 'Product not found.' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieve a product by ID.
+ *
+ * @param int $id The product ID.
+ * @param ?array $_query_info Unified query info tree from the GraphQL request.
+ * @return ?object
+ */
+ #[ReturnType( Product::class )]
+ public function execute(
+ #[Description( 'The ID of the product to retrieve.' )]
+ int $id,
+ ?array $_query_info = null,
+ ): ?object {
+ // Mirrors the guard in authorize(): never pass a non-positive ID to
+ // wc_get_product(). authorize() would normally reject these first,
+ // but a future caller path might invoke execute() directly.
+ if ( $id <= 0 ) {
+ return null;
+ }
+
+ $wc_product = wc_get_product( $id );
+
+ if ( ! $wc_product instanceof \WC_Product ) {
+ return null;
+ }
+
+ return ProductMapper::from_wc_product( $wc_product, $_query_info );
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Products/ListProducts.php b/plugins/woocommerce/src/Api/Queries/Products/ListProducts.php
new file mode 100644
index 00000000000..b67849e8d88
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Queries/Products/ListProducts.php
@@ -0,0 +1,226 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Queries\Products;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\InputTypes\Products\ProductFilterInput;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;
+
+/**
+ * Query to list products with cursor-based pagination.
+ *
+ * Demonstrates: #[Unroll] on parameter, enum as direct param, multiple capabilities.
+ */
+#[Name( 'products' )]
+#[Description( 'List products with cursor-based pagination and optional filtering.' )]
+#[RequiredCapability( 'manage_woocommerce' )]
+#[RequiredCapability( 'edit_products' )]
+class ListProducts {
+ /**
+ * List products with optional filtering and pagination.
+ *
+ * @param PaginationParams $pagination The pagination parameters.
+ * @param ProductFilterInput $filters Filter criteria (unrolled to flat args).
+ * @param ?ProductType $product_type Optional product type filter.
+ * @param ?array $_query_info Unified query info tree from the GraphQL request.
+ * @return Connection
+ * @throws ApiException When an unsupported `stock_status` filter value is passed.
+ */
+ #[ConnectionOf( Product::class )]
+ public function execute(
+ PaginationParams $pagination,
+ #[Unroll]
+ ProductFilterInput $filters,
+ #[Description( 'Filter by product type.' )]
+ ?ProductType $product_type = null,
+ ?array $_query_info = null,
+ ): Connection {
+ $first = $pagination->first;
+ $last = $pagination->last;
+ $after = $pagination->after;
+ $before = $pagination->before;
+ $limit = $first ?? $last ?? PaginationParams::get_default_page_size();
+
+ $query_args = array(
+ 'post_type' => 'product',
+ 'posts_per_page' => $limit + 1,
+ 'orderby' => 'ID',
+ 'order' => null !== $last ? 'DESC' : 'ASC',
+ 'post_status' => $filters->status?->value ?? 'any',
+ );
+
+ // Product type filter via taxonomy. `ProductType::Other` is the
+ // output-only signal for "stored product_type doesn't match any
+ // known standard" (typically plugin-added types), mirroring how
+ // `StockStatus::Other` is handled for the meta-query path above.
+ // Map it to NOT IN the standard slugs rather than the literal
+ // 'other' term, which wouldn't match anything.
+ if ( null !== $product_type ) {
+ if ( ProductType::Other === $product_type ) {
+ $query_args['tax_query'] = array(
+ array(
+ 'taxonomy' => 'product_type',
+ 'field' => 'slug',
+ 'terms' => array_values(
+ array_filter(
+ array_map(
+ static fn( ProductType $t ): string => $t->value,
+ ProductType::cases()
+ ),
+ static fn( string $slug ): bool => ProductType::Other->value !== $slug
+ )
+ ),
+ 'operator' => 'NOT IN',
+ ),
+ );
+ } else {
+ $query_args['tax_query'] = array(
+ array(
+ 'taxonomy' => 'product_type',
+ 'field' => 'slug',
+ 'terms' => $product_type->value,
+ ),
+ );
+ }
+ }
+
+ // Stock status filter via meta. `StockStatus::Other` means "stored
+ // _stock_status isn't one of the three standard WooCommerce values"
+ // (typically a plugin-added custom status), so it maps to NOT IN
+ // those three. `default` throws INVALID_ARGUMENT so any future
+ // enum case added without updating this match fails loudly with a
+ // clean 400 instead of a PHP-level UnhandledMatchError → HTTP 500.
+ if ( null !== $filters->stock_status ) {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ $meta_clause = match ( $filters->stock_status ) {
+ StockStatus::InStock => array(
+ 'key' => '_stock_status',
+ 'value' => 'instock',
+ ),
+ StockStatus::OutOfStock => array(
+ 'key' => '_stock_status',
+ 'value' => 'outofstock',
+ ),
+ StockStatus::OnBackorder => array(
+ 'key' => '_stock_status',
+ 'value' => 'onbackorder',
+ ),
+ StockStatus::Other => array(
+ 'key' => '_stock_status',
+ 'value' => array( 'instock', 'outofstock', 'onbackorder' ),
+ 'compare' => 'NOT IN',
+ ),
+ default => throw new ApiException(
+ sprintf( 'Unsupported stock_status filter value: %s.', $filters->stock_status->name ),
+ 'INVALID_ARGUMENT',
+ status_code: 400,
+ ),
+ };
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ $query_args['meta_query'] = array( $meta_clause );
+ }
+
+ // Search filter.
+ if ( null !== $filters->search ) {
+ $query_args['s'] = $filters->search;
+ }
+
+ // Total count query. Derive from $query_args — which already has
+ // the tax_query / meta_query / search clauses applied — *before*
+ // we set cursor query vars on it. Building $count_args from scratch
+ // with only post_status would drop every user filter and report the
+ // count of "all products in that status" instead of "all products
+ // matching the filters", making Relay consumers' "X of Y" wrong.
+ // Only `found_posts` is read, so posts_per_page => 1 keeps the
+ // underlying SELECT cheap.
+ $count_args = $query_args;
+ $count_args['posts_per_page'] = 1;
+ $count_args['fields'] = 'ids';
+ $count_query = new \WP_Query( $count_args );
+ $total_count = $count_query->found_posts;
+
+ // Cursor-based filtering via IdCursorFilter (see class docblock).
+ if ( null !== $after ) {
+ $query_args[ IdCursorFilter::AFTER_ID ] = IdCursorFilter::decode_id_cursor( $after, 'after' );
+ }
+ if ( null !== $before ) {
+ $query_args[ IdCursorFilter::BEFORE_ID ] = IdCursorFilter::decode_id_cursor( $before, 'before' );
+ }
+ IdCursorFilter::ensure_registered();
+
+ $query = new \WP_Query( $query_args );
+ $posts = $query->posts;
+
+ // Determine pagination.
+ $has_extra = count( $posts ) > $limit;
+ if ( $has_extra ) {
+ $posts = array_slice( $posts, 0, $limit );
+ }
+
+ if ( null !== $last ) {
+ $posts = array_reverse( $posts );
+ }
+
+ // Narrow $_query_info to the per-node selection so each mapped
+ // product only fetches the subtrees the client actually asked for
+ // under `nodes { ... }` / `edges { node { ... } }`. Without this,
+ // ProductMapper::populate_common_fields() hits its null-$query_info
+ // fallback and runs build_reviews() (plus its count query) for
+ // every product on the page — N+1 on reviews even when no client
+ // selected them.
+ $node_query_info = ProductMapper::connection_node_info( $_query_info );
+
+ // Build edges and nodes.
+ $edges = array();
+ $nodes = array();
+ foreach ( $posts as $post ) {
+ $wc_product = wc_get_product( $post->ID );
+ if ( ! $wc_product instanceof \WC_Product ) {
+ continue;
+ }
+
+ $product = ProductMapper::from_wc_product( $wc_product, $node_query_info );
+
+ $edge = new Edge();
+ $edge->cursor = base64_encode( (string) $product->id );
+ $edge->node = $product;
+
+ $edges[] = $edge;
+ $nodes[] = $product;
+ }
+
+ $page_info = new PageInfo();
+ // Relay semantics for backward pagination (`last`, `before`): the
+ // returned window ends just before `$before`, so items after the
+ // window exist whenever `$before` was supplied — not whenever
+ // `$after` was. `has_previous_page` in the backward case is driven
+ // by the "did we fetch limit+1?" sentinel (`$has_extra`).
+ $page_info->has_next_page = null !== $last ? ( null !== $before ) : $has_extra;
+ $page_info->has_previous_page = null !== $last ? $has_extra : ( null !== $after );
+ $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
+ $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+ $connection = new Connection();
+ $connection->edges = $edges;
+ $connection->nodes = $nodes;
+ $connection->page_info = $page_info;
+ $connection->total_count = $total_count;
+
+ return $connection;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/README.md b/plugins/woocommerce/src/Api/README.md
new file mode 100644
index 00000000000..14a74710990
--- /dev/null
+++ b/plugins/woocommerce/src/Api/README.md
@@ -0,0 +1,7 @@
+# Important: Experimental feature
+
+All the code in this directory (`Automattic\WooCommerce\Api` namespace and nested namespaces) is part of [an experimental feature](https://github.com/woocommerce/woocommerce/pull/63772). The code could (and probably will) get backwards-incompatible changes, or even be completely removed, in future releases of WooCommerce.
+
+Feel free to experiment in testing or staging environments, but **DO NOT** use this code in released extensions or in production environments.
+
+Also as a reminder, **ALL** the code that's inside the `Automattic\WooCommerce\Internal` namespace and nested namespaces, or that's annotated with `@internal`, is for exclusive usage of WooCommerce core and must **NEVER** be used in extensions or otherwise in production environments.
diff --git a/plugins/woocommerce/src/Api/Scalars/DateTime.php b/plugins/woocommerce/src/Api/Scalars/DateTime.php
new file mode 100644
index 00000000000..a393643e22b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Scalars/DateTime.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Scalars;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Custom scalar for ISO 8601 date/time values.
+ */
+#[Description( 'An ISO 8601 encoded date and time string.' )]
+class DateTime {
+ /**
+ * Serialize a PHP value to the scalar's transport format.
+ *
+ * @param mixed $value The value to serialize.
+ * @return string
+ */
+ public static function serialize( mixed $value ): string {
+ if ( $value instanceof \DateTimeInterface ) {
+ return $value->format( \DateTimeInterface::ATOM );
+ }
+ return (string) $value;
+ }
+
+ /**
+ * Parse a value received from a client (variable or literal).
+ *
+ * @param string $value The raw string value from the client.
+ * @return \DateTimeImmutable
+ * @throws \InvalidArgumentException When the value cannot be parsed as an ISO 8601 date/time string.
+ */
+ public static function parse( string $value ): \DateTimeImmutable {
+ try {
+ return new \DateTimeImmutable( $value );
+ } catch ( \Exception $e ) {
+ // PHP 8.3+ throws \DateMalformedStringException; earlier versions
+ // throw a plain \Exception. Both extend \Exception, so a single
+ // catch captures them.
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML output; serialized as JSON in the GraphQL error response.
+ throw new \InvalidArgumentException(
+ sprintf( 'Invalid ISO 8601 date/time: %s', $e->getMessage() ),
+ 0,
+ $e
+ );
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Traits/RequiresManageWoocommerce.php b/plugins/woocommerce/src/Api/Traits/RequiresManageWoocommerce.php
new file mode 100644
index 00000000000..61d925f4292
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Traits/RequiresManageWoocommerce.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Traits;
+
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+
+/**
+ * Trait that grants the manage_woocommerce capability requirement.
+ *
+ * Classes using this trait inherit the capability via the builder's
+ * resolve_capabilities() method, which inspects traits for attributes.
+ */
+#[RequiredCapability( 'manage_woocommerce' )]
+trait RequiresManageWoocommerce {
+}
diff --git a/plugins/woocommerce/src/Api/Types/Coupons/Coupon.php b/plugins/woocommerce/src/Api/Types/Coupons/Coupon.php
new file mode 100644
index 00000000000..e7cc1fa6d39
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Coupons/Coupon.php
@@ -0,0 +1,105 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\Interfaces\ObjectWithId;
+use Automattic\WooCommerce\Api\Scalars\DateTime;
+
+/**
+ * Output type representing a WooCommerce coupon.
+ */
+#[Description( 'Represents a WooCommerce discount coupon.' )]
+class Coupon {
+ use ObjectWithId;
+
+ #[Description( 'The coupon code.' )]
+ public string $code;
+
+ #[Description( 'The coupon description.' )]
+ public string $description;
+
+ #[Description( 'The type of discount.' )]
+ public DiscountType $discount_type;
+
+ #[Description( 'The raw discount type as stored in WooCommerce. Useful when discount_type is OTHER (e.g. plugin-added types like recurring_percent or sign_up_fee).' )]
+ public string $raw_discount_type;
+
+ #[Description( 'The discount amount.' )]
+ public float $amount;
+
+ #[Description( 'The coupon status.' )]
+ public CouponStatus $status;
+
+ #[Description( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).' )]
+ public string $raw_status;
+
+ #[Description( 'The date the coupon was created.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_created;
+
+ #[Description( 'The date the coupon was last modified.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_modified;
+
+ #[Description( 'The date the coupon expires.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_expires;
+
+ #[Description( 'The number of times the coupon has been used.' )]
+ public int $usage_count;
+
+ #[Description( 'Whether the coupon can only be used alone.' )]
+ public bool $individual_use;
+
+ #[Description( 'Product IDs the coupon can be applied to.' )]
+ #[ArrayOf( 'int' )]
+ public array $product_ids;
+
+ #[Description( 'Product IDs excluded from the coupon.' )]
+ #[ArrayOf( 'int' )]
+ public array $excluded_product_ids;
+
+ #[Description( 'Maximum number of times the coupon can be used in total.' )]
+ public int $usage_limit;
+
+ #[Description( 'Maximum number of times the coupon can be used per customer.' )]
+ public int $usage_limit_per_user;
+
+ #[Description( 'Maximum number of items the coupon can be applied to.' )]
+ public ?int $limit_usage_to_x_items;
+
+ #[Description( 'Whether the coupon grants free shipping.' )]
+ public bool $free_shipping;
+
+ #[Description( 'Product category IDs the coupon applies to.' )]
+ #[ArrayOf( 'int' )]
+ public array $product_categories;
+
+ #[Description( 'Product category IDs excluded from the coupon.' )]
+ #[ArrayOf( 'int' )]
+ public array $excluded_product_categories;
+
+ #[Description( 'Whether the coupon excludes items on sale.' )]
+ public bool $exclude_sale_items;
+
+ #[Description( 'Minimum order amount required to use the coupon.' )]
+ public float $minimum_amount;
+
+ #[Description( 'Maximum order amount allowed to use the coupon.' )]
+ public float $maximum_amount;
+
+ #[Description( 'Email addresses that can use this coupon.' )]
+ #[ArrayOf( 'string' )]
+ public array $email_restrictions;
+
+ #[Description( 'Email addresses of customers who have used this coupon.' )]
+ #[ArrayOf( 'string' )]
+ public array $used_by;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Coupons/DeleteCouponResult.php b/plugins/woocommerce/src/Api/Types/Coupons/DeleteCouponResult.php
new file mode 100644
index 00000000000..e1c75c2048f
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Coupons/DeleteCouponResult.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Coupons;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Result of a coupon deletion operation.
+ */
+#[Description( 'The result of deleting a coupon.' )]
+class DeleteCouponResult {
+ #[Description( 'The ID of the deleted coupon.' )]
+ public int $id;
+
+ #[Description( 'Whether the coupon was permanently deleted.' )]
+ public bool $deleted;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ExternalProduct.php b/plugins/woocommerce/src/Api/Types/Products/ExternalProduct.php
new file mode 100644
index 00000000000..65e0a01c0b8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ExternalProduct.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+
+/**
+ * Output type representing an external/affiliate product.
+ */
+#[Description( 'An external/affiliate product.' )]
+class ExternalProduct {
+ use Product;
+
+ #[Description( 'The external product URL.' )]
+ public ?string $product_url;
+
+ #[Description( 'The text for the external product button.' )]
+ public ?string $button_text;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductAttribute.php b/plugins/woocommerce/src/Api/Types/Products/ProductAttribute.php
new file mode 100644
index 00000000000..afb3950112a
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductAttribute.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing a product attribute definition.
+ */
+#[Description( 'A product attribute.' )]
+class ProductAttribute {
+ #[Description( 'The attribute display name.' )]
+ public string $name;
+
+ #[Description( 'The attribute taxonomy or key name.' )]
+ public string $slug;
+
+ #[Description( 'The available attribute values.' )]
+ #[ArrayOf( 'string' )]
+ public array $options;
+
+ #[Description( 'The display order position.' )]
+ public int $position;
+
+ #[Description( 'Whether the attribute is visible on the product page.' )]
+ public bool $visible;
+
+ #[Description( 'Whether the attribute is used for variations.' )]
+ public bool $variation;
+
+ #[Description( 'Whether the attribute is a global taxonomy attribute.' )]
+ public bool $is_taxonomy;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductDimensions.php b/plugins/woocommerce/src/Api/Types/Products/ProductDimensions.php
new file mode 100644
index 00000000000..fcef48187c9
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductDimensions.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing product physical dimensions.
+ */
+#[Description( 'Physical dimensions and weight of a product.' )]
+class ProductDimensions {
+ #[Description( 'The product length.' )]
+ public ?float $length;
+
+ #[Description( 'The product width.' )]
+ public ?float $width;
+
+ #[Description( 'The product height.' )]
+ public ?float $height;
+
+ #[Description( 'The product weight.' )]
+ public ?float $weight;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductImage.php b/plugins/woocommerce/src/Api/Types/Products/ProductImage.php
new file mode 100644
index 00000000000..9dfae3d203e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductImage.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing a product image.
+ */
+#[Description( 'Represents a product image.' )]
+class ProductImage {
+ #[Description( 'The image attachment ID.' )]
+ public int $id;
+
+ #[Description( 'The image URL.' )]
+ public string $url;
+
+ #[Description( 'The image alt text.' )]
+ public string $alt;
+
+ #[Description( 'The image display position.' )]
+ public int $position;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductReview.php b/plugins/woocommerce/src/Api/Types/Products/ProductReview.php
new file mode 100644
index 00000000000..c4e63cb1427
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductReview.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Scalars\DateTime;
+
+/**
+ * Output type representing a product review.
+ */
+#[Description( 'Represents a customer review for a product.' )]
+class ProductReview {
+ #[Description( 'The review ID.' )]
+ public int $id;
+
+ #[Description( 'The product ID this review belongs to.' )]
+ public int $product_id;
+
+ #[Description( 'The reviewer name.' )]
+ public string $reviewer;
+
+ #[Description( 'The review content.' )]
+ public string $review;
+
+ #[Description( 'The review rating (1-5).' )]
+ public int $rating;
+
+ #[Description( 'The date the review was created.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_created;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/ProductVariation.php b/plugins/woocommerce/src/Api/Types/Products/ProductVariation.php
new file mode 100644
index 00000000000..dffd11e5961
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/ProductVariation.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+
+/**
+ * Output type representing a product variation.
+ */
+#[Description( 'A product variation.' )]
+class ProductVariation {
+ use Product;
+
+ #[Description( 'The parent variable product ID.' )]
+ public int $parent_id;
+
+ #[Description( 'The selected attribute values for this variation.' )]
+ #[ArrayOf( SelectedAttribute::class )]
+ public array $selected_attributes;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/SelectedAttribute.php b/plugins/woocommerce/src/Api/Types/Products/SelectedAttribute.php
new file mode 100644
index 00000000000..6c5e3d05d0c
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/SelectedAttribute.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+
+/**
+ * Output type representing a single attribute selection on a variation.
+ */
+#[Description( 'A selected attribute value on a product variation.' )]
+class SelectedAttribute {
+ #[Description( 'The attribute name or slug.' )]
+ public string $name;
+
+ #[Description( 'The selected attribute value.' )]
+ public string $value;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/SimpleProduct.php b/plugins/woocommerce/src/Api/Types/Products/SimpleProduct.php
new file mode 100644
index 00000000000..0baebc7069b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/SimpleProduct.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+
+/**
+ * Output type representing a simple WooCommerce product.
+ */
+#[Description( 'A simple WooCommerce product.' )]
+class SimpleProduct {
+ use Product;
+}
diff --git a/plugins/woocommerce/src/Api/Types/Products/VariableProduct.php b/plugins/woocommerce/src/Api/Types/Products/VariableProduct.php
new file mode 100644
index 00000000000..2984efb2143
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Types/Products/VariableProduct.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Types\Products;
+
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Interfaces\Product;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+
+/**
+ * Output type representing a variable product with variations.
+ */
+#[Description( 'A variable product with variations.' )]
+class VariableProduct {
+ use Product;
+
+ #[Description( 'The product variations.' )]
+ #[ConnectionOf( ProductVariation::class )]
+ #[Parameter( type: PaginationParams::class )]
+ public Connection $variations;
+}
diff --git a/plugins/woocommerce/src/Api/Utils/Coupons/CouponMapper.php b/plugins/woocommerce/src/Api/Utils/Coupons/CouponMapper.php
new file mode 100644
index 00000000000..898f8e7efb8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/Coupons/CouponMapper.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils\Coupons;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus;
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType;
+use Automattic\WooCommerce\Api\Types\Coupons\Coupon;
+
+/**
+ * Maps a WC_Coupon to the Coupon DTO.
+ */
+class CouponMapper {
+ /**
+ * Map a WC_Coupon to the Coupon DTO.
+ *
+ * @param \WC_Coupon $wc_coupon The WooCommerce coupon object.
+ * @return Coupon
+ */
+ public static function from_wc_coupon( \WC_Coupon $wc_coupon ): Coupon {
+ $coupon = new Coupon();
+
+ $raw_discount_type = (string) $wc_coupon->get_discount_type();
+ $raw_status = (string) $wc_coupon->get_status();
+
+ $coupon->id = $wc_coupon->get_id();
+ $coupon->code = $wc_coupon->get_code();
+ $coupon->description = $wc_coupon->get_description();
+ $coupon->discount_type = DiscountType::tryFrom( $raw_discount_type ) ?? DiscountType::Other;
+ $coupon->raw_discount_type = $raw_discount_type;
+ $coupon->amount = (float) $wc_coupon->get_amount();
+ $coupon->status = '' === $raw_status
+ ? CouponStatus::Draft
+ : ( CouponStatus::tryFrom( $raw_status ) ?? CouponStatus::Other );
+ $coupon->raw_status = $raw_status;
+ $coupon->date_created = $wc_coupon->get_date_created()?->format( \DateTimeInterface::ATOM );
+ $coupon->date_modified = $wc_coupon->get_date_modified()?->format( \DateTimeInterface::ATOM );
+ $coupon->date_expires = $wc_coupon->get_date_expires()?->format( \DateTimeInterface::ATOM );
+ $coupon->usage_count = $wc_coupon->get_usage_count();
+ $coupon->individual_use = $wc_coupon->get_individual_use();
+ $coupon->product_ids = $wc_coupon->get_product_ids();
+ $coupon->excluded_product_ids = $wc_coupon->get_excluded_product_ids();
+ $coupon->usage_limit = $wc_coupon->get_usage_limit();
+ $coupon->usage_limit_per_user = $wc_coupon->get_usage_limit_per_user();
+ $coupon->limit_usage_to_x_items = $wc_coupon->get_limit_usage_to_x_items();
+ $coupon->free_shipping = $wc_coupon->get_free_shipping();
+ $coupon->product_categories = $wc_coupon->get_product_categories();
+ $coupon->excluded_product_categories = $wc_coupon->get_excluded_product_categories();
+ $coupon->exclude_sale_items = $wc_coupon->get_exclude_sale_items();
+ $coupon->minimum_amount = (float) $wc_coupon->get_minimum_amount();
+ $coupon->maximum_amount = (float) $wc_coupon->get_maximum_amount();
+ $coupon->email_restrictions = $wc_coupon->get_email_restrictions();
+ $coupon->used_by = $wc_coupon->get_used_by();
+
+ return $coupon;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Utils/Products/ProductMapper.php b/plugins/woocommerce/src/Api/Utils/Products/ProductMapper.php
new file mode 100644
index 00000000000..04c4bebc265
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/Products/ProductMapper.php
@@ -0,0 +1,564 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils\Products;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus;
+use Automattic\WooCommerce\Api\Enums\Products\ProductType;
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Api\Pagination\Edge;
+use Automattic\WooCommerce\Api\Pagination\IdCursorFilter;
+use Automattic\WooCommerce\Api\Pagination\PageInfo;
+use Automattic\WooCommerce\Api\Pagination\PaginationParams;
+use Automattic\WooCommerce\Api\Types\Products\ExternalProduct;
+use Automattic\WooCommerce\Api\Types\Products\ProductAttribute;
+use Automattic\WooCommerce\Api\Types\Products\ProductDimensions;
+use Automattic\WooCommerce\Api\Types\Products\ProductImage;
+use Automattic\WooCommerce\Api\Types\Products\ProductReview;
+use Automattic\WooCommerce\Api\Types\Products\ProductVariation;
+use Automattic\WooCommerce\Api\Types\Products\SelectedAttribute;
+use Automattic\WooCommerce\Api\Types\Products\SimpleProduct;
+use Automattic\WooCommerce\Api\Types\Products\VariableProduct;
+
+/**
+ * Maps a WC_Product to the appropriate product DTO.
+ */
+class ProductMapper {
+ /**
+ * Map a WC_Product to the appropriate product DTO based on its type.
+ *
+ * @param \WC_Product $wc_product The WooCommerce product object.
+ * @param ?array $query_info Unified query info tree from the GraphQL request.
+ * @return object
+ */
+ public static function from_wc_product(
+ \WC_Product $wc_product,
+ ?array $query_info = null,
+ ): object {
+ $product = match ( $wc_product->get_type() ) {
+ 'external' => self::build_external_product( $wc_product ),
+ 'variable' => self::build_variable_product( $wc_product, $query_info ),
+ 'variation' => self::build_product_variation( $wc_product ),
+ default => new SimpleProduct(),
+ };
+
+ self::populate_common_fields( $product, $wc_product, $query_info );
+
+ return $product;
+ }
+
+ /**
+ * Build an ExternalProduct with type-specific fields.
+ *
+ * @param \WC_Product $wc_product The external product.
+ * @return ExternalProduct
+ */
+ private static function build_external_product( \WC_Product $wc_product ): ExternalProduct {
+ $product = new ExternalProduct();
+
+ $url = $wc_product->get_product_url();
+ $product->product_url = ! empty( $url ) ? $url : null;
+ $text = $wc_product->get_button_text();
+ $product->button_text = ! empty( $text ) ? $text : null;
+
+ return $product;
+ }
+
+ /**
+ * Build a VariableProduct with type-specific fields.
+ *
+ * @param \WC_Product $wc_product The variable product.
+ * @param ?array $query_info Unified query info tree from the GraphQL request.
+ * @return VariableProduct
+ */
+ private static function build_variable_product( \WC_Product $wc_product, ?array $query_info = null ): VariableProduct {
+ $product = new VariableProduct();
+
+ $child_ids = $wc_product->get_children();
+ $total_count = count( $child_ids );
+
+ // Extract the per-variation selection and pagination args from
+ // $query_info up front. Narrowing $query_info keeps recursive
+ // from_wc_product() calls from fetching subtrees the client didn't
+ // request (e.g. reviews for every variation).
+ $variations_info = $query_info['...VariableProduct']['variations']
+ ?? $query_info['variations']
+ ?? null;
+ $variation_query_info = self::connection_node_info( $variations_info );
+ $pagination_args = $variations_info['__args'] ?? array();
+
+ // Slice the ID window *before* mapping: otherwise `variations(first: 1)`
+ // on a product with N variations would prime+map all N just to slice
+ // the result down afterwards. The resolver-level validation at
+ // Connection::slice() is now bypassed (we're building a pre-sliced
+ // connection), so call validate_args() explicitly to keep the 0..
+ // MAX_PAGE_SIZE bounds enforced.
+ PaginationParams::validate_args( $pagination_args );
+ $page = self::slice_variation_ids( $child_ids, $pagination_args );
+
+ // Prime post + meta caches for only the paged subset.
+ if ( ! empty( $page['ids'] ) ) {
+ _prime_post_caches( $page['ids'] );
+ }
+
+ $edges = array();
+ $nodes = array();
+ foreach ( $page['ids'] as $child_id ) {
+ $child_product = wc_get_product( $child_id );
+ if ( ! $child_product ) {
+ continue;
+ }
+
+ $variation = self::from_wc_product( $child_product, $variation_query_info );
+
+ $edge = new Edge();
+ $edge->cursor = base64_encode( (string) $child_id );
+ $edge->node = $variation;
+
+ $edges[] = $edge;
+ $nodes[] = $variation;
+ }
+
+ $page_info = new PageInfo();
+ $page_info->has_next_page = $page['has_next_page'];
+ $page_info->has_previous_page = $page['has_previous_page'];
+ $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
+ $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+ // total_count reflects the full variation set, not the paged one —
+ // consistent with how the root list resolvers compute it.
+ $product->variations = Connection::pre_sliced( $edges, $page_info, $total_count );
+
+ return $product;
+ }
+
+ /**
+ * Compute a Relay cursor page against a list of variation IDs.
+ *
+ * Mirrors the logic in {@see Connection::slice()} but operates on raw
+ * IDs so the caller can page-down *before* calling `wc_get_product()`
+ * + `from_wc_product()` on each child. Returns the paged IDs and the
+ * corresponding `has_next_page` / `has_previous_page` flags in Relay
+ * semantics.
+ *
+ * @param int[] $child_ids Full variation ID list, in menu_order.
+ * @param array $args `{first?, last?, after?, before?}` raw GraphQL args.
+ * @return array{ids: int[], has_next_page: bool, has_previous_page: bool}
+ */
+ private static function slice_variation_ids( array $child_ids, array $args ): array {
+ $first = $args['first'] ?? null;
+ $last = $args['last'] ?? null;
+ $after = $args['after'] ?? null;
+ $before = $args['before'] ?? null;
+
+ // No pagination requested — return the full list as-is.
+ if ( null === $first && null === $last && null === $after && null === $before ) {
+ return array(
+ 'ids' => array_values( $child_ids ),
+ 'has_next_page' => false,
+ 'has_previous_page' => false,
+ );
+ }
+
+ // Narrow by `after`: drop IDs up to and including the cursor position.
+ if ( null !== $after ) {
+ $after_id = IdCursorFilter::decode_id_cursor( $after, 'after' );
+ $idx = array_search( $after_id, $child_ids, true );
+ $child_ids = false !== $idx ? array_slice( $child_ids, $idx + 1 ) : array();
+ }
+
+ // Narrow by `before`: drop IDs from the cursor position onward.
+ if ( null !== $before ) {
+ $before_id = IdCursorFilter::decode_id_cursor( $before, 'before' );
+ $idx = array_search( $before_id, $child_ids, true );
+ if ( false !== $idx ) {
+ $child_ids = array_slice( $child_ids, 0, $idx );
+ }
+ }
+
+ $total_after_cursors = count( $child_ids );
+
+ // Apply first/last limits.
+ if ( null !== $first && $first >= 0 ) {
+ $child_ids = array_slice( $child_ids, 0, $first );
+ }
+ if ( null !== $last && $last >= 0 ) {
+ $child_ids = array_slice( $child_ids, max( 0, count( $child_ids ) - $last ) );
+ }
+
+ // Relay semantics for the forward / backward branches match what
+ // ListProducts / ListCoupons use at the root level.
+ return array(
+ 'ids' => array_values( $child_ids ),
+ 'has_next_page' =>
+ null !== $first ? count( $child_ids ) < $total_after_cursors : ( null !== $before ),
+ 'has_previous_page' =>
+ null !== $last ? count( $child_ids ) < $total_after_cursors : ( null !== $after ),
+ );
+ }
+
+ /**
+ * Build a ProductVariation with type-specific fields.
+ *
+ * @param \WC_Product $wc_product The variation product.
+ * @return ProductVariation
+ */
+ private static function build_product_variation( \WC_Product $wc_product ): ProductVariation {
+ $product = new ProductVariation();
+ $product->parent_id = $wc_product->get_parent_id();
+
+ $selected_attributes = array();
+ foreach ( $wc_product->get_attributes() as $taxonomy => $value ) {
+ $attr = new SelectedAttribute();
+ $attr->name = $taxonomy;
+
+ // For taxonomy attributes, resolve the slug to a human-readable term name.
+ if ( taxonomy_exists( $taxonomy ) && ! empty( $value ) ) {
+ $term = get_term_by( 'slug', $value, $taxonomy );
+ if ( $term && ! is_wp_error( $term ) ) {
+ $attr->value = $term->name;
+ } else {
+ $attr->value = $value;
+ }
+ } else {
+ $attr->value = $value;
+ }
+
+ $selected_attributes[] = $attr;
+ }
+ $product->selected_attributes = $selected_attributes;
+
+ return $product;
+ }
+
+ /**
+ * Populate the common fields shared by all product types.
+ *
+ * @param object $product The product DTO to populate.
+ * @param \WC_Product $wc_product The WooCommerce product object.
+ * @param ?array $query_info Unified query info tree from the GraphQL request.
+ */
+ private static function populate_common_fields(
+ object $product,
+ \WC_Product $wc_product,
+ ?array $query_info,
+ ): void {
+ $raw_status = (string) $wc_product->get_status();
+ $raw_product_type = (string) $wc_product->get_type();
+
+ $product->id = $wc_product->get_id();
+ $product->name = $wc_product->get_name();
+ $product->slug = $wc_product->get_slug();
+ $sku = $wc_product->get_sku();
+ $product->sku = '' !== $sku ? $sku : null;
+ $product->description = $wc_product->get_description();
+ $product->short_description = $wc_product->get_short_description();
+ $product->status = ProductStatus::tryFrom( $raw_status ) ?? ProductStatus::Other;
+ $product->raw_status = $raw_status;
+ $product->product_type = ProductType::tryFrom( $raw_product_type ) ?? ProductType::Other;
+ $product->raw_product_type = $raw_product_type;
+
+ // Price fields support a "formatted" argument for currency display.
+ // An empty stored value means "not set" and is surfaced as null —
+ // without this, wc_price( (float) '' ) would render as "$0.00" and
+ // be indistinguishable from a genuinely-zero price.
+ $format_regular = $query_info['regular_price']['__args']['formatted'] ?? true;
+ $raw_regular = $wc_product->get_regular_price();
+ if ( '' === $raw_regular ) {
+ $product->regular_price = null;
+ } else {
+ $product->regular_price = $format_regular
+ ? wc_price( (float) $raw_regular )
+ : $raw_regular;
+ }
+
+ $format_sale = $query_info['sale_price']['__args']['formatted'] ?? true;
+ $raw_sale = $wc_product->get_sale_price();
+ if ( '' === $raw_sale ) {
+ $product->sale_price = null;
+ } else {
+ $product->sale_price = $format_sale
+ ? wc_price( (float) $raw_sale )
+ : $raw_sale;
+ }
+
+ $raw_stock_status = (string) $wc_product->get_stock_status();
+ $product->stock_status = self::map_stock_status( $raw_stock_status );
+ $product->raw_stock_status = $raw_stock_status;
+ $product->stock_quantity = $wc_product->get_stock_quantity();
+
+ // Nested output type: dimensions.
+ $product->dimensions = self::build_dimensions( $wc_product );
+
+ // Array of objects: images.
+ $product->images = self::build_images( $wc_product );
+
+ // Array of objects: attributes.
+ $product->attributes = self::build_attributes( $wc_product );
+
+ // Sub-collection connection: reviews.
+ // Only populate if explicitly requested (optimization via $query_info).
+ if ( null === $query_info || array_key_exists( 'reviews', $query_info ) ) {
+ $product->reviews = self::build_reviews( $wc_product->get_id() );
+ } else {
+ $product->reviews = self::empty_connection();
+ }
+
+ $product->date_created = $wc_product->get_date_created()?->format( \DateTimeInterface::ATOM );
+ $product->date_modified = $wc_product->get_date_modified()?->format( \DateTimeInterface::ATOM );
+
+ // Ignored field — set to null; it won't appear in the schema.
+ $product->internal_notes = null;
+ }
+
+ /**
+ * Map WooCommerce stock status string to the int-backed StockStatus enum.
+ *
+ * @param string $wc_status The WC stock status string.
+ * @return StockStatus
+ */
+ private static function map_stock_status( string $wc_status ): StockStatus {
+ return match ( $wc_status ) {
+ 'instock' => StockStatus::InStock,
+ 'outofstock' => StockStatus::OutOfStock,
+ 'onbackorder' => StockStatus::OnBackorder,
+ default => StockStatus::Other,
+ };
+ }
+
+ /**
+ * Build product dimensions from a WC_Product.
+ *
+ * @param \WC_Product $wc_product The product.
+ * @return ?ProductDimensions
+ */
+ private static function build_dimensions( \WC_Product $wc_product ): ?ProductDimensions {
+ $length = $wc_product->get_length();
+ $width = $wc_product->get_width();
+ $height = $wc_product->get_height();
+ $weight = $wc_product->get_weight();
+
+ if ( '' === $length && '' === $width && '' === $height && '' === $weight ) {
+ return null;
+ }
+
+ $dims = new ProductDimensions();
+ $dims->length = '' !== $length ? (float) $length : null;
+ $dims->width = '' !== $width ? (float) $width : null;
+ $dims->height = '' !== $height ? (float) $height : null;
+ $dims->weight = '' !== $weight ? (float) $weight : null;
+
+ return $dims;
+ }
+
+ /**
+ * Build product images from a WC_Product.
+ *
+ * @param \WC_Product $wc_product The product.
+ * @return ProductImage[]
+ */
+ private static function build_images( \WC_Product $wc_product ): array {
+ $images = array();
+ $position = 0;
+
+ // Include the featured image first.
+ $featured_id = $wc_product->get_image_id();
+ if ( $featured_id ) {
+ $image = self::build_image( (int) $featured_id, $position );
+ if ( null !== $image ) {
+ $images[] = $image;
+ ++$position;
+ }
+ }
+
+ // Then gallery images.
+ foreach ( $wc_product->get_gallery_image_ids() as $image_id ) {
+ $image = self::build_image( (int) $image_id, $position );
+ if ( null !== $image ) {
+ $images[] = $image;
+ ++$position;
+ }
+ }
+
+ return $images;
+ }
+
+ /**
+ * Build product attributes from a WC_Product.
+ *
+ * For variations, attributes are simple key→value pairs (handled by selected_attributes),
+ * so this returns an empty array. For other product types, it returns full attribute definitions.
+ *
+ * @param \WC_Product $wc_product The product.
+ * @return ProductAttribute[]
+ */
+ private static function build_attributes( \WC_Product $wc_product ): array {
+ // Variations store attributes as simple string values, not WC_Product_Attribute objects.
+ if ( 'variation' === $wc_product->get_type() ) {
+ return array();
+ }
+
+ $attributes = array();
+ foreach ( $wc_product->get_attributes() as $wc_attr ) {
+ if ( ! $wc_attr instanceof \WC_Product_Attribute ) {
+ continue;
+ }
+
+ $attr = new ProductAttribute();
+ $attr->slug = $wc_attr->get_name();
+
+ if ( $wc_attr->is_taxonomy() ) {
+ $attr->name = wc_attribute_label( $wc_attr->get_name() );
+ $attr->options = array_map(
+ function ( $term ) {
+ return $term->name;
+ },
+ $wc_attr->get_terms() ? $wc_attr->get_terms() : array()
+ );
+ } else {
+ $attr->name = $wc_attr->get_name();
+ $attr->options = $wc_attr->get_options();
+ }
+
+ $attr->position = $wc_attr->get_position();
+ $attr->visible = $wc_attr->get_visible();
+ $attr->variation = $wc_attr->get_variation();
+ $attr->is_taxonomy = $wc_attr->is_taxonomy();
+
+ $attributes[] = $attr;
+ }//end foreach
+
+ return $attributes;
+ }
+
+ /**
+ * Build a single ProductImage from an attachment ID.
+ *
+ * @param int $attachment_id The WordPress attachment ID.
+ * @param int $position The display position.
+ * @return ?ProductImage
+ */
+ private static function build_image( int $attachment_id, int $position ): ?ProductImage {
+ $url = wp_get_attachment_url( $attachment_id );
+ if ( ! $url ) {
+ return null;
+ }
+
+ $image = new ProductImage();
+ $image->id = $attachment_id;
+ $image->url = $url;
+ $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
+ $image->alt = ! empty( $alt ) ? $alt : '';
+ $image->position = $position;
+
+ return $image;
+ }
+
+ /**
+ * Build a reviews connection for a product.
+ *
+ * @param int $product_id The product ID.
+ * @return Connection
+ */
+ private static function build_reviews( int $product_id ): Connection {
+ $base_args = array(
+ 'post_id' => $product_id,
+ 'type' => 'review',
+ 'status' => 'approve',
+ );
+
+ // Separate count query: otherwise `total_count` would be the page
+ // size (capped at 10) instead of the real review total.
+ $total_count = (int) get_comments( $base_args + array( 'count' => true ) );
+
+ $comments = get_comments(
+ $base_args + array(
+ 'orderby' => 'comment_date',
+ 'order' => 'DESC',
+ 'number' => 10,
+ )
+ );
+
+ $edges = array();
+ $nodes = array();
+
+ foreach ( $comments as $comment ) {
+ $review = new ProductReview();
+ $review->id = (int) $comment->comment_ID;
+ $review->product_id = $product_id;
+ $review->reviewer = $comment->comment_author;
+ $review->review = $comment->comment_content;
+ $review->rating = (int) get_comment_meta( $comment->comment_ID, 'rating', true );
+ $review->date_created = $comment->comment_date_gmt
+ ? ( new \DateTimeImmutable( $comment->comment_date_gmt, new \DateTimeZone( 'UTC' ) ) )->format( \DateTimeInterface::ATOM )
+ : null;
+
+ $edge = new Edge();
+ $edge->cursor = base64_encode( (string) $review->id );
+ $edge->node = $review;
+
+ $edges[] = $edge;
+ $nodes[] = $review;
+ }
+
+ $page_info = new PageInfo();
+ $page_info->has_next_page = $total_count > count( $comments );
+ $page_info->has_previous_page = false;
+ $page_info->start_cursor = ! empty( $edges ) ? $edges[0]->cursor : null;
+ $page_info->end_cursor = ! empty( $edges ) ? $edges[ count( $edges ) - 1 ]->cursor : null;
+
+ $connection = new Connection();
+ $connection->edges = $edges;
+ $connection->nodes = $nodes;
+ $connection->page_info = $page_info;
+ $connection->total_count = $total_count;
+
+ return $connection;
+ }
+
+ /**
+ * Extract the per-node selection from a connection's query_info entry.
+ *
+ * Connections can be queried via `nodes { ... }` (the plain form) or
+ * `edges { node { ... } }` (Relay form); clients may use either or both.
+ * The per-node selection is what gets forwarded to the recursive
+ * mapper call so each node is built with the right sub-fields.
+ *
+ * @param ?array $connection_info The query_info entry for the connection (e.g. `$query_info['variations']`).
+ * @return ?array The merged per-node selection, or null when the caller didn't request any node fields.
+ */
+ public static function connection_node_info( ?array $connection_info ): ?array {
+ if ( null === $connection_info ) {
+ return null;
+ }
+ $nodes = is_array( $connection_info['nodes'] ?? null ) ? $connection_info['nodes'] : array();
+ $edge = is_array( $connection_info['edges']['node'] ?? null ) ? $connection_info['edges']['node'] : array();
+ if ( empty( $nodes ) && empty( $edge ) ) {
+ return null;
+ }
+ return array_merge( $edge, $nodes );
+ }
+
+ /**
+ * Return an empty connection (for skipped sub-collections).
+ *
+ * @return Connection
+ */
+ private static function empty_connection(): Connection {
+ $page_info = new PageInfo();
+ $page_info->has_next_page = false;
+ $page_info->has_previous_page = false;
+ $page_info->start_cursor = null;
+ $page_info->end_cursor = null;
+
+ $connection = new Connection();
+ $connection->edges = array();
+ $connection->nodes = array();
+ $connection->page_info = $page_info;
+ $connection->total_count = 0;
+
+ return $connection;
+ }
+}
diff --git a/plugins/woocommerce/src/Api/Utils/Products/ProductRepository.php b/plugins/woocommerce/src/Api/Utils/Products/ProductRepository.php
new file mode 100644
index 00000000000..99e8a641ff9
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/Products/ProductRepository.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils\Products;
+
+/**
+ * Repository for product persistence operations.
+ *
+ * Designed to be injected via the DI container into commands
+ * that need to load or save products.
+ */
+class ProductRepository {
+ /**
+ * Find a product by ID.
+ *
+ * @param int $id The product ID.
+ * @return ?\WC_Product The product, or null if not found.
+ */
+ public function find( int $id ): ?\WC_Product {
+ $product = wc_get_product( $id );
+ return $product instanceof \WC_Product ? $product : null;
+ }
+
+ /**
+ * Save a product.
+ *
+ * @param \WC_Product $product The product to save.
+ */
+ public function save( \WC_Product $product ): void {
+ $product->save();
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
new file mode 100644
index 00000000000..3bf7a736ea3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
@@ -0,0 +1,128 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Coupons\CreateCoupon as CreateCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\CreateCoupon as CreateCouponInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class CreateCoupon {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( CouponType::get() ),
+ 'description' => __( 'Create a new coupon.', 'woocommerce' ),
+ 'args' => array(
+ 'input' => array(
+ 'type' => Type::nonNull( CreateCouponInput::get() ),
+ 'description' => __( 'Data for the new coupon.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_woocommerce' );
+
+ $command = wc_get_container()->get( CreateCouponCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'input', $args ) ) {
+ $execute_args['input'] = self::convert_create_coupon_input( $args['input'] );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ private static function convert_create_coupon_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput {
+ $input = new \Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput();
+
+ if ( array_key_exists( 'code', $data ) ) {
+ $input->mark_provided( 'code' );
+ $input->code = $data['code'];
+ }
+ if ( array_key_exists( 'description', $data ) ) {
+ $input->mark_provided( 'description' );
+ $input->description = $data['description'];
+ }
+ if ( array_key_exists( 'discount_type', $data ) ) {
+ $input->mark_provided( 'discount_type' );
+ $input->discount_type = $data['discount_type'];
+ }
+ if ( array_key_exists( 'amount', $data ) ) {
+ $input->mark_provided( 'amount' );
+ $input->amount = $data['amount'];
+ }
+ if ( array_key_exists( 'status', $data ) ) {
+ $input->mark_provided( 'status' );
+ $input->status = $data['status'];
+ }
+ if ( array_key_exists( 'date_expires', $data ) ) {
+ $input->mark_provided( 'date_expires' );
+ $input->date_expires = $data['date_expires'];
+ }
+ if ( array_key_exists( 'individual_use', $data ) ) {
+ $input->mark_provided( 'individual_use' );
+ $input->individual_use = $data['individual_use'];
+ }
+ if ( array_key_exists( 'product_ids', $data ) ) {
+ $input->mark_provided( 'product_ids' );
+ $input->product_ids = $data['product_ids'];
+ }
+ if ( array_key_exists( 'excluded_product_ids', $data ) ) {
+ $input->mark_provided( 'excluded_product_ids' );
+ $input->excluded_product_ids = $data['excluded_product_ids'];
+ }
+ if ( array_key_exists( 'usage_limit', $data ) ) {
+ $input->mark_provided( 'usage_limit' );
+ $input->usage_limit = $data['usage_limit'];
+ }
+ if ( array_key_exists( 'usage_limit_per_user', $data ) ) {
+ $input->mark_provided( 'usage_limit_per_user' );
+ $input->usage_limit_per_user = $data['usage_limit_per_user'];
+ }
+ if ( array_key_exists( 'limit_usage_to_x_items', $data ) ) {
+ $input->mark_provided( 'limit_usage_to_x_items' );
+ $input->limit_usage_to_x_items = $data['limit_usage_to_x_items'];
+ }
+ if ( array_key_exists( 'free_shipping', $data ) ) {
+ $input->mark_provided( 'free_shipping' );
+ $input->free_shipping = $data['free_shipping'];
+ }
+ if ( array_key_exists( 'product_categories', $data ) ) {
+ $input->mark_provided( 'product_categories' );
+ $input->product_categories = $data['product_categories'];
+ }
+ if ( array_key_exists( 'excluded_product_categories', $data ) ) {
+ $input->mark_provided( 'excluded_product_categories' );
+ $input->excluded_product_categories = $data['excluded_product_categories'];
+ }
+ if ( array_key_exists( 'exclude_sale_items', $data ) ) {
+ $input->mark_provided( 'exclude_sale_items' );
+ $input->exclude_sale_items = $data['exclude_sale_items'];
+ }
+ if ( array_key_exists( 'minimum_amount', $data ) ) {
+ $input->mark_provided( 'minimum_amount' );
+ $input->minimum_amount = $data['minimum_amount'];
+ }
+ if ( array_key_exists( 'maximum_amount', $data ) ) {
+ $input->mark_provided( 'maximum_amount' );
+ $input->maximum_amount = $data['maximum_amount'];
+ }
+ if ( array_key_exists( 'email_restrictions', $data ) ) {
+ $input->mark_provided( 'email_restrictions' );
+ $input->email_restrictions = $data['email_restrictions'];
+ }
+
+ return $input;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
new file mode 100644
index 00000000000..cce22433600
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
@@ -0,0 +1,123 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Products\CreateProduct as CreateProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\CreateProduct as CreateProductInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class CreateProduct {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( ProductInterface::get() ),
+ 'description' => __( 'Create a new product.', 'woocommerce' ),
+ 'args' => array(
+ 'input' => array(
+ 'type' => Type::nonNull( CreateProductInput::get() ),
+ 'description' => __( 'Data for the new product.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'edit_products' );
+
+ $command = wc_get_container()->get( CreateProductCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'input', $args ) ) {
+ $execute_args['input'] = self::convert_create_product_input( $args['input'] );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ private static function convert_dimensions_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput {
+ $input = new \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput();
+
+ if ( array_key_exists( 'length', $data ) ) {
+ $input->mark_provided( 'length' );
+ $input->length = $data['length'];
+ }
+ if ( array_key_exists( 'width', $data ) ) {
+ $input->mark_provided( 'width' );
+ $input->width = $data['width'];
+ }
+ if ( array_key_exists( 'height', $data ) ) {
+ $input->mark_provided( 'height' );
+ $input->height = $data['height'];
+ }
+ if ( array_key_exists( 'weight', $data ) ) {
+ $input->mark_provided( 'weight' );
+ $input->weight = $data['weight'];
+ }
+
+ return $input;
+ }
+
+ private static function convert_create_product_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput {
+ $input = new \Automattic\WooCommerce\Api\InputTypes\Products\CreateProductInput();
+
+ if ( array_key_exists( 'name', $data ) ) {
+ $input->mark_provided( 'name' );
+ $input->name = $data['name'];
+ }
+ if ( array_key_exists( 'slug', $data ) ) {
+ $input->mark_provided( 'slug' );
+ $input->slug = $data['slug'];
+ }
+ if ( array_key_exists( 'sku', $data ) ) {
+ $input->mark_provided( 'sku' );
+ $input->sku = $data['sku'];
+ }
+ if ( array_key_exists( 'description', $data ) ) {
+ $input->mark_provided( 'description' );
+ $input->description = $data['description'];
+ }
+ if ( array_key_exists( 'short_description', $data ) ) {
+ $input->mark_provided( 'short_description' );
+ $input->short_description = $data['short_description'];
+ }
+ if ( array_key_exists( 'status', $data ) ) {
+ $input->mark_provided( 'status' );
+ $input->status = $data['status'];
+ }
+ if ( array_key_exists( 'product_type', $data ) ) {
+ $input->mark_provided( 'product_type' );
+ $input->product_type = $data['product_type'];
+ }
+ if ( array_key_exists( 'regular_price', $data ) ) {
+ $input->mark_provided( 'regular_price' );
+ $input->regular_price = $data['regular_price'];
+ }
+ if ( array_key_exists( 'sale_price', $data ) ) {
+ $input->mark_provided( 'sale_price' );
+ $input->sale_price = $data['sale_price'];
+ }
+ if ( array_key_exists( 'manage_stock', $data ) ) {
+ $input->mark_provided( 'manage_stock' );
+ $input->manage_stock = $data['manage_stock'];
+ }
+ if ( array_key_exists( 'stock_quantity', $data ) ) {
+ $input->mark_provided( 'stock_quantity' );
+ $input->stock_quantity = $data['stock_quantity'];
+ }
+ if ( array_key_exists( 'dimensions', $data ) ) {
+ $input->mark_provided( 'dimensions' );
+ $input->dimensions = null !== $data['dimensions'] ? self::convert_dimensions_input( $data['dimensions'] ) : null;
+ }
+
+ return $input;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
new file mode 100644
index 00000000000..68a812df341
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Coupons\DeleteCoupon as DeleteCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\DeleteCouponResult as DeleteCouponResultType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class DeleteCoupon {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( DeleteCouponResultType::get() ),
+ 'description' => __( 'Delete a coupon.', 'woocommerce' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The ID of the coupon to delete.', 'woocommerce' ),
+ ),
+ 'force' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether to permanently delete the coupon (bypass trash).', 'woocommerce' ),
+ 'defaultValue' => false,
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_woocommerce' );
+
+ $command = wc_get_container()->get( DeleteCouponCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'id', $args ) ) {
+ $execute_args['id'] = $args['id'];
+ }
+ if ( array_key_exists( 'force', $args ) ) {
+ $execute_args['force'] = $args['force'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
new file mode 100644
index 00000000000..202eaab89c8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
@@ -0,0 +1,60 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Products\DeleteProduct as DeleteProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class DeleteProduct {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull(
+ new \GraphQL\Type\Definition\ObjectType(
+ array(
+ 'name' => 'DeleteProductResult',
+ 'fields' => array(
+ 'result' => array( 'type' => Type::nonNull( Type::boolean() ) ),
+ ),
+ )
+ )
+ ),
+ 'description' => __( 'Delete a product.', 'woocommerce' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The ID of the product to delete.', 'woocommerce' ),
+ ),
+ 'force' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether to permanently delete the product (bypass trash).', 'woocommerce' ),
+ 'defaultValue' => false,
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_woocommerce' );
+
+ $command = wc_get_container()->get( DeleteProductCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'id', $args ) ) {
+ $execute_args['id'] = $args['id'];
+ }
+ if ( array_key_exists( 'force', $args ) ) {
+ $execute_args['force'] = $args['force'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return array( 'result' => $result );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
new file mode 100644
index 00000000000..73dc05ab0a4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
@@ -0,0 +1,132 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Coupons\UpdateCoupon as UpdateCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\UpdateCoupon as UpdateCouponInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class UpdateCoupon {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( CouponType::get() ),
+ 'description' => __( 'Update an existing coupon.', 'woocommerce' ),
+ 'args' => array(
+ 'input' => array(
+ 'type' => Type::nonNull( UpdateCouponInput::get() ),
+ 'description' => __( 'The fields to update.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_woocommerce' );
+
+ $command = wc_get_container()->get( UpdateCouponCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'input', $args ) ) {
+ $execute_args['input'] = self::convert_update_coupon_input( $args['input'] );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ private static function convert_update_coupon_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput {
+ $input = new \Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput();
+
+ if ( array_key_exists( 'id', $data ) ) {
+ $input->mark_provided( 'id' );
+ $input->id = $data['id'];
+ }
+ if ( array_key_exists( 'code', $data ) ) {
+ $input->mark_provided( 'code' );
+ $input->code = $data['code'];
+ }
+ if ( array_key_exists( 'description', $data ) ) {
+ $input->mark_provided( 'description' );
+ $input->description = $data['description'];
+ }
+ if ( array_key_exists( 'discount_type', $data ) ) {
+ $input->mark_provided( 'discount_type' );
+ $input->discount_type = $data['discount_type'];
+ }
+ if ( array_key_exists( 'amount', $data ) ) {
+ $input->mark_provided( 'amount' );
+ $input->amount = $data['amount'];
+ }
+ if ( array_key_exists( 'status', $data ) ) {
+ $input->mark_provided( 'status' );
+ $input->status = $data['status'];
+ }
+ if ( array_key_exists( 'date_expires', $data ) ) {
+ $input->mark_provided( 'date_expires' );
+ $input->date_expires = $data['date_expires'];
+ }
+ if ( array_key_exists( 'individual_use', $data ) ) {
+ $input->mark_provided( 'individual_use' );
+ $input->individual_use = $data['individual_use'];
+ }
+ if ( array_key_exists( 'product_ids', $data ) ) {
+ $input->mark_provided( 'product_ids' );
+ $input->product_ids = $data['product_ids'];
+ }
+ if ( array_key_exists( 'excluded_product_ids', $data ) ) {
+ $input->mark_provided( 'excluded_product_ids' );
+ $input->excluded_product_ids = $data['excluded_product_ids'];
+ }
+ if ( array_key_exists( 'usage_limit', $data ) ) {
+ $input->mark_provided( 'usage_limit' );
+ $input->usage_limit = $data['usage_limit'];
+ }
+ if ( array_key_exists( 'usage_limit_per_user', $data ) ) {
+ $input->mark_provided( 'usage_limit_per_user' );
+ $input->usage_limit_per_user = $data['usage_limit_per_user'];
+ }
+ if ( array_key_exists( 'limit_usage_to_x_items', $data ) ) {
+ $input->mark_provided( 'limit_usage_to_x_items' );
+ $input->limit_usage_to_x_items = $data['limit_usage_to_x_items'];
+ }
+ if ( array_key_exists( 'free_shipping', $data ) ) {
+ $input->mark_provided( 'free_shipping' );
+ $input->free_shipping = $data['free_shipping'];
+ }
+ if ( array_key_exists( 'product_categories', $data ) ) {
+ $input->mark_provided( 'product_categories' );
+ $input->product_categories = $data['product_categories'];
+ }
+ if ( array_key_exists( 'excluded_product_categories', $data ) ) {
+ $input->mark_provided( 'excluded_product_categories' );
+ $input->excluded_product_categories = $data['excluded_product_categories'];
+ }
+ if ( array_key_exists( 'exclude_sale_items', $data ) ) {
+ $input->mark_provided( 'exclude_sale_items' );
+ $input->exclude_sale_items = $data['exclude_sale_items'];
+ }
+ if ( array_key_exists( 'minimum_amount', $data ) ) {
+ $input->mark_provided( 'minimum_amount' );
+ $input->minimum_amount = $data['minimum_amount'];
+ }
+ if ( array_key_exists( 'maximum_amount', $data ) ) {
+ $input->mark_provided( 'maximum_amount' );
+ $input->maximum_amount = $data['maximum_amount'];
+ }
+ if ( array_key_exists( 'email_restrictions', $data ) ) {
+ $input->mark_provided( 'email_restrictions' );
+ $input->email_restrictions = $data['email_restrictions'];
+ }
+
+ return $input;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
new file mode 100644
index 00000000000..c8706bc73f2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
@@ -0,0 +1,127 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Api\Mutations\Products\UpdateProduct as UpdateProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\UpdateProduct as UpdateProductInput;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class UpdateProduct {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( ProductInterface::get() ),
+ 'description' => __( 'Update an existing product.', 'woocommerce' ),
+ 'args' => array(
+ 'input' => array(
+ 'type' => Type::nonNull( UpdateProductInput::get() ),
+ 'description' => __( 'The fields to update.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_woocommerce' );
+
+ $command = wc_get_container()->get( UpdateProductCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'input', $args ) ) {
+ $execute_args['input'] = self::convert_update_product_input( $args['input'] );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+
+ private static function convert_dimensions_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput {
+ $input = new \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput();
+
+ if ( array_key_exists( 'length', $data ) ) {
+ $input->mark_provided( 'length' );
+ $input->length = $data['length'];
+ }
+ if ( array_key_exists( 'width', $data ) ) {
+ $input->mark_provided( 'width' );
+ $input->width = $data['width'];
+ }
+ if ( array_key_exists( 'height', $data ) ) {
+ $input->mark_provided( 'height' );
+ $input->height = $data['height'];
+ }
+ if ( array_key_exists( 'weight', $data ) ) {
+ $input->mark_provided( 'weight' );
+ $input->weight = $data['weight'];
+ }
+
+ return $input;
+ }
+
+ private static function convert_update_product_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput {
+ $input = new \Automattic\WooCommerce\Api\InputTypes\Products\UpdateProductInput();
+
+ if ( array_key_exists( 'id', $data ) ) {
+ $input->mark_provided( 'id' );
+ $input->id = $data['id'];
+ }
+ if ( array_key_exists( 'name', $data ) ) {
+ $input->mark_provided( 'name' );
+ $input->name = $data['name'];
+ }
+ if ( array_key_exists( 'slug', $data ) ) {
+ $input->mark_provided( 'slug' );
+ $input->slug = $data['slug'];
+ }
+ if ( array_key_exists( 'sku', $data ) ) {
+ $input->mark_provided( 'sku' );
+ $input->sku = $data['sku'];
+ }
+ if ( array_key_exists( 'description', $data ) ) {
+ $input->mark_provided( 'description' );
+ $input->description = $data['description'];
+ }
+ if ( array_key_exists( 'short_description', $data ) ) {
+ $input->mark_provided( 'short_description' );
+ $input->short_description = $data['short_description'];
+ }
+ if ( array_key_exists( 'status', $data ) ) {
+ $input->mark_provided( 'status' );
+ $input->status = $data['status'];
+ }
+ if ( array_key_exists( 'product_type', $data ) ) {
+ $input->mark_provided( 'product_type' );
+ $input->product_type = $data['product_type'];
+ }
+ if ( array_key_exists( 'regular_price', $data ) ) {
+ $input->mark_provided( 'regular_price' );
+ $input->regular_price = $data['regular_price'];
+ }
+ if ( array_key_exists( 'sale_price', $data ) ) {
+ $input->mark_provided( 'sale_price' );
+ $input->sale_price = $data['sale_price'];
+ }
+ if ( array_key_exists( 'manage_stock', $data ) ) {
+ $input->mark_provided( 'manage_stock' );
+ $input->manage_stock = $data['manage_stock'];
+ }
+ if ( array_key_exists( 'stock_quantity', $data ) ) {
+ $input->mark_provided( 'stock_quantity' );
+ $input->stock_quantity = $data['stock_quantity'];
+ }
+ if ( array_key_exists( 'dimensions', $data ) ) {
+ $input->mark_provided( 'dimensions' );
+ $input->dimensions = null !== $data['dimensions'] ? self::convert_dimensions_input( $data['dimensions'] ) : null;
+ }
+
+ return $input;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
new file mode 100644
index 00000000000..3be3f63b5ea
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
@@ -0,0 +1,53 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Coupons\GetCoupon as GetCouponCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class GetCoupon {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => CouponType::get(),
+ 'description' => __( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.', 'woocommerce' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The ID of the coupon to retrieve.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ 'code' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The coupon code to look up.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'read_private_shop_coupons' );
+
+ $command = wc_get_container()->get( GetCouponCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'id', $args ) ) {
+ $execute_args['id'] = $args['id'];
+ }
+ if ( array_key_exists( 'code', $args ) ) {
+ $execute_args['code'] = $args['code'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
new file mode 100644
index 00000000000..74c9abd2459
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
@@ -0,0 +1,56 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Products\GetProduct as GetProductCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class GetProduct {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => ProductInterface::get(),
+ 'description' => __( 'Retrieve a single product by ID.', 'woocommerce' ),
+ 'args' => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The ID of the product to retrieve.', 'woocommerce' ),
+ ),
+ ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ $command = wc_get_container()->get( GetProductCommand::class );
+
+ $execute_args = array();
+ if ( array_key_exists( 'id', $args ) ) {
+ $execute_args['id'] = $args['id'];
+ }
+ $execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+
+ if ( ! Utils::authorize_command(
+ $command,
+ array(
+ 'id' => $execute_args['id'],
+ '_preauthorized' => current_user_can( 'read_product' ),
+ )
+ ) ) {
+ throw new \GraphQL\Error\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
new file mode 100644
index 00000000000..31ba564f3bc
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
@@ -0,0 +1,68 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Coupons\ListCoupons as ListCouponsCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\CouponConnection as CouponConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class ListCoupons {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( CouponConnectionType::get() ),
+ 'description' => __( 'List coupons with cursor-based pagination.', 'woocommerce' ),
+ 'args' => array(
+ 'first' => array(
+ '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,
+ ),
+ 'status' => array(
+ 'type' => CouponStatusType::get(),
+ 'description' => __( 'Filter by coupon status.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ ),
+ 'complexity' => Utils::complexity_from_pagination( ... ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'read_private_shop_coupons' );
+
+ $command = wc_get_container()->get( ListCouponsCommand::class );
+
+ $execute_args = array();
+ $execute_args['pagination'] = Utils::create_pagination_params( $args );
+ if ( array_key_exists( 'status', $args ) ) {
+ $execute_args['status'] = $args['status'];
+ }
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
new file mode 100644
index 00000000000..288c0a7fb0c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
@@ -0,0 +1,94 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Api\Queries\Products\ListProducts as ListProductsCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductConnection as ProductConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class ListProducts {
+ public static function get_field_definition(): array {
+ return array(
+ 'type' => Type::nonNull( ProductConnectionType::get() ),
+ 'description' => __( 'List products with cursor-based pagination and optional filtering.', 'woocommerce' ),
+ 'args' => array(
+ 'first' => array(
+ '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,
+ ),
+ 'status' => array(
+ 'type' => ProductStatusType::get(),
+ 'description' => __( 'Filter by product status.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ 'stock_status' => array(
+ 'type' => StockStatusType::get(),
+ 'description' => __( 'Filter by stock status.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ 'search' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Search products by keyword.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ 'product_type' => array(
+ 'type' => ProductTypeType::get(),
+ 'description' => __( 'Filter by product type.', 'woocommerce' ),
+ 'defaultValue' => null,
+ ),
+ ),
+ 'complexity' => Utils::complexity_from_pagination( ... ),
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+ Utils::check_current_user_can( 'manage_woocommerce' );
+ Utils::check_current_user_can( 'edit_products' );
+
+ $command = wc_get_container()->get( ListProductsCommand::class );
+
+ $execute_args = array();
+ $execute_args['pagination'] = Utils::create_pagination_params( $args );
+ $execute_args['filters'] = Utils::create_input(
+ fn() => new \Automattic\WooCommerce\Api\InputTypes\Products\ProductFilterInput(
+ status: $args['status'],
+ stock_status: $args['stock_status'],
+ search: $args['search'] ?? null,
+ )
+ );
+ if ( array_key_exists( 'product_type', $args ) ) {
+ $execute_args['product_type'] = $args['product_type'];
+ }
+ $execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+
+ $result = Utils::execute_command( $command, $execute_args );
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/CouponStatus.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/CouponStatus.php
new file mode 100644
index 00000000000..9fa17254a17
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/CouponStatus.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\CouponStatus as CouponStatusEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class CouponStatus {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'CouponStatus',
+ 'description' => __( 'The publication status of a coupon.', 'woocommerce' ),
+ 'values' => array(
+ 'PUBLISHED' => array(
+ 'value' => CouponStatusEnum::Published,
+ 'description' => __( 'The coupon is published and active.', 'woocommerce' ),
+ ),
+ 'DRAFT' => array(
+ 'value' => CouponStatusEnum::Draft,
+ 'description' => __( 'The coupon is a draft.', 'woocommerce' ),
+ ),
+ 'PENDING' => array(
+ 'value' => CouponStatusEnum::Pending,
+ 'description' => __( 'The coupon is pending review.', 'woocommerce' ),
+ ),
+ 'PRIVATE' => array(
+ 'value' => CouponStatusEnum::Private,
+ 'description' => __( 'The coupon is privately published.', 'woocommerce' ),
+ ),
+ 'FUTURE' => array(
+ 'value' => CouponStatusEnum::Future,
+ 'description' => __( 'The coupon is scheduled to be published in the future.', 'woocommerce' ),
+ ),
+ 'TRASH' => array(
+ 'value' => CouponStatusEnum::Trash,
+ 'description' => __( 'The coupon is in the trash.', 'woocommerce' ),
+ ),
+ 'OTHER' => array(
+ 'value' => CouponStatusEnum::Other,
+ 'description' => __( 'The coupon status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/DiscountType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/DiscountType.php
new file mode 100644
index 00000000000..2b105555b3c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/DiscountType.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Coupons\DiscountType as DiscountTypeEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class DiscountType {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'DiscountType',
+ 'description' => __( 'The type of discount for a coupon.', 'woocommerce' ),
+ 'values' => array(
+ 'PERCENT' => array(
+ 'value' => DiscountTypeEnum::Percent,
+ 'description' => __( 'A percentage discount.', 'woocommerce' ),
+ ),
+ 'FIXED_CART' => array(
+ 'value' => DiscountTypeEnum::FixedCart,
+ 'description' => __( 'A fixed amount discount applied to the cart.', 'woocommerce' ),
+ ),
+ 'FIXED_PRODUCT' => array(
+ 'value' => DiscountTypeEnum::FixedProduct,
+ 'description' => __( 'A fixed amount discount applied to each eligible product.', 'woocommerce' ),
+ ),
+ 'OTHER' => array(
+ 'value' => DiscountTypeEnum::Other,
+ 'description' => __( 'The discount type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_discount_type for the underlying value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductStatus.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductStatus.php
new file mode 100644
index 00000000000..ca81d4016a0
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductStatus.php
@@ -0,0 +1,56 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductStatus as ProductStatusEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class ProductStatus {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'ProductStatus',
+ 'description' => __( 'The publication status of a product.', 'woocommerce' ),
+ 'values' => array(
+ 'DRAFT' => array(
+ 'value' => ProductStatusEnum::Draft,
+ 'description' => __( 'The product is a draft.', 'woocommerce' ),
+ ),
+ 'PENDING' => array(
+ 'value' => ProductStatusEnum::Pending,
+ 'description' => __( 'The product is pending review.', 'woocommerce' ),
+ ),
+ 'ACTIVE' => array(
+ 'value' => ProductStatusEnum::Published,
+ 'description' => __( 'The product is published and visible.', 'woocommerce' ),
+ ),
+ 'PRIVATE' => array(
+ 'value' => ProductStatusEnum::Private,
+ 'description' => __( 'The product is privately published.', 'woocommerce' ),
+ ),
+ 'FUTURE' => array(
+ 'value' => ProductStatusEnum::Future,
+ 'description' => __( 'The product is scheduled to be published in the future.', 'woocommerce' ),
+ ),
+ 'TRASH' => array(
+ 'value' => ProductStatusEnum::Trash,
+ 'description' => __( 'The product is in the trash.', 'woocommerce' ),
+ 'deprecationReason' => 'Trashed products should be excluded via status filter.',
+ ),
+ 'OTHER' => array(
+ 'value' => ProductStatusEnum::Other,
+ 'description' => __( 'The product status is not one of the standard WordPress values (e.g. added by a plugin). Inspect raw_status for the underlying value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductType.php
new file mode 100644
index 00000000000..c24684d12e7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/ProductType.php
@@ -0,0 +1,51 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Products\ProductType as ProductTypeEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class ProductType {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'ProductType',
+ 'description' => __( 'The type of a WooCommerce product.', 'woocommerce' ),
+ 'values' => array(
+ 'SIMPLE' => array(
+ 'value' => ProductTypeEnum::Simple,
+ 'description' => __( 'A simple product.', 'woocommerce' ),
+ ),
+ 'GROUPED' => array(
+ 'value' => ProductTypeEnum::Grouped,
+ 'description' => __( 'A grouped product.', 'woocommerce' ),
+ ),
+ 'EXTERNAL' => array(
+ 'value' => ProductTypeEnum::External,
+ 'description' => __( 'An external/affiliate product.', 'woocommerce' ),
+ ),
+ 'VARIABLE' => array(
+ 'value' => ProductTypeEnum::Variable,
+ 'description' => __( 'A variable product with variations.', 'woocommerce' ),
+ ),
+ 'VARIATION' => array(
+ 'value' => ProductTypeEnum::Variation,
+ 'description' => __( 'A product variation.', 'woocommerce' ),
+ ),
+ 'OTHER' => array(
+ 'value' => ProductTypeEnum::Other,
+ 'description' => __( 'The product type is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_product_type for the underlying value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/StockStatus.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/StockStatus.php
new file mode 100644
index 00000000000..a28c18b46ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Enums/StockStatus.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums;
+
+use Automattic\WooCommerce\Api\Enums\Products\StockStatus as StockStatusEnum;
+use GraphQL\Type\Definition\EnumType;
+
+class StockStatus {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => 'StockStatus',
+ 'description' => __( 'The stock status of a product.', 'woocommerce' ),
+ 'values' => array(
+ 'IN_STOCK' => array(
+ 'value' => StockStatusEnum::InStock,
+ 'description' => __( 'The product is in stock.', 'woocommerce' ),
+ ),
+ 'OUT_OF_STOCK' => array(
+ 'value' => StockStatusEnum::OutOfStock,
+ 'description' => __( 'The product is out of stock.', 'woocommerce' ),
+ ),
+ 'ON_BACKORDER' => array(
+ 'value' => StockStatusEnum::OnBackorder,
+ 'description' => __( 'The product is on backorder.', 'woocommerce' ),
+ ),
+ 'OTHER' => array(
+ 'value' => StockStatusEnum::Other,
+ 'description' => __( 'The stock status is not one of the standard WooCommerce values (e.g. added by a plugin). Inspect raw_stock_status for the underlying value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateCoupon.php
new file mode 100644
index 00000000000..85df973eaee
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateCoupon.php
@@ -0,0 +1,105 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\DiscountType as DiscountTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CreateCoupon {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'CreateCouponInput',
+ 'description' => __( 'Data required to create a new coupon.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'code' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The coupon code.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The coupon description.', 'woocommerce' ),
+ ),
+ 'discount_type' => array(
+ 'type' => DiscountTypeType::get(),
+ 'description' => __( 'The type of discount.', 'woocommerce' ),
+ ),
+ 'amount' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The discount amount.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => CouponStatusType::get(),
+ 'description' => __( 'The coupon status.', 'woocommerce' ),
+ ),
+ 'date_expires' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The date the coupon expires (ISO 8601).', 'woocommerce' ),
+ ),
+ 'individual_use' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether the coupon can only be used alone.', 'woocommerce' ),
+ ),
+ 'product_ids' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product IDs the coupon can be applied to.', 'woocommerce' ),
+ ),
+ 'excluded_product_ids' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product IDs excluded from the coupon.', 'woocommerce' ),
+ ),
+ 'usage_limit' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of times the coupon can be used in total.', 'woocommerce' ),
+ ),
+ 'usage_limit_per_user' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of times the coupon can be used per customer.', 'woocommerce' ),
+ ),
+ 'limit_usage_to_x_items' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of items the coupon can be applied to.', 'woocommerce' ),
+ ),
+ 'free_shipping' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether the coupon grants free shipping.', 'woocommerce' ),
+ ),
+ 'product_categories' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product category IDs the coupon applies to.', 'woocommerce' ),
+ ),
+ 'excluded_product_categories' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product category IDs excluded from the coupon.', 'woocommerce' ),
+ ),
+ 'exclude_sale_items' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether the coupon excludes items on sale.', 'woocommerce' ),
+ ),
+ 'minimum_amount' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'Minimum order amount required to use the coupon.', 'woocommerce' ),
+ ),
+ 'maximum_amount' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'Maximum order amount allowed to use the coupon.', 'woocommerce' ),
+ ),
+ 'email_restrictions' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::string() ) ),
+ 'description' => __( 'Email addresses that can use this coupon.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateProduct.php
new file mode 100644
index 00000000000..e2b5306964b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/CreateProduct.php
@@ -0,0 +1,78 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\Dimensions as DimensionsInput;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CreateProduct {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'CreateProductInput',
+ 'description' => __( 'Data required to create a new product.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => ProductStatusType::get(),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => ProductTypeType::get(),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The regular price.', 'woocommerce' ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The sale price.', 'woocommerce' ),
+ ),
+ 'manage_stock' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether to manage stock.', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => DimensionsInput::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/Dimensions.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/Dimensions.php
new file mode 100644
index 00000000000..fa2b8018763
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/Dimensions.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class Dimensions {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'DimensionsInput',
+ 'description' => __( 'Physical dimensions and weight for a product.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'length' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product length.', 'woocommerce' ),
+ ),
+ 'width' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product width.', 'woocommerce' ),
+ ),
+ 'height' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product height.', 'woocommerce' ),
+ ),
+ 'weight' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product weight.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/ProductFilter.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/ProductFilter.php
new file mode 100644
index 00000000000..a7c039c5d1d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/ProductFilter.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductFilter {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'ProductFilterInput',
+ 'description' => __( 'Filter criteria for listing products.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'status' => array(
+ 'type' => ProductStatusType::get(),
+ 'description' => __( 'Filter by product status.', 'woocommerce' ),
+ ),
+ 'stock_status' => array(
+ 'type' => StockStatusType::get(),
+ 'description' => __( 'Filter by stock status.', 'woocommerce' ),
+ ),
+ 'search' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'Search products by keyword.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateCoupon.php
new file mode 100644
index 00000000000..e03a703981a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateCoupon.php
@@ -0,0 +1,109 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\DiscountType as DiscountTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class UpdateCoupon {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'UpdateCouponInput',
+ 'description' => __( 'Data for updating an existing coupon. All fields are optional.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The ID of the coupon to update.', 'woocommerce' ),
+ ),
+ 'code' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The coupon code.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The coupon description.', 'woocommerce' ),
+ ),
+ 'discount_type' => array(
+ 'type' => DiscountTypeType::get(),
+ 'description' => __( 'The type of discount.', 'woocommerce' ),
+ ),
+ 'amount' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The discount amount.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => CouponStatusType::get(),
+ 'description' => __( 'The coupon status.', 'woocommerce' ),
+ ),
+ 'date_expires' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The date the coupon expires (ISO 8601).', 'woocommerce' ),
+ ),
+ 'individual_use' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether the coupon can only be used alone.', 'woocommerce' ),
+ ),
+ 'product_ids' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product IDs the coupon can be applied to.', 'woocommerce' ),
+ ),
+ 'excluded_product_ids' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product IDs excluded from the coupon.', 'woocommerce' ),
+ ),
+ 'usage_limit' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of times the coupon can be used in total.', 'woocommerce' ),
+ ),
+ 'usage_limit_per_user' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of times the coupon can be used per customer.', 'woocommerce' ),
+ ),
+ 'limit_usage_to_x_items' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of items the coupon can be applied to.', 'woocommerce' ),
+ ),
+ 'free_shipping' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether the coupon grants free shipping.', 'woocommerce' ),
+ ),
+ 'product_categories' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product category IDs the coupon applies to.', 'woocommerce' ),
+ ),
+ 'excluded_product_categories' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::int() ) ),
+ 'description' => __( 'Product category IDs excluded from the coupon.', 'woocommerce' ),
+ ),
+ 'exclude_sale_items' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether the coupon excludes items on sale.', 'woocommerce' ),
+ ),
+ 'minimum_amount' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'Minimum order amount required to use the coupon.', 'woocommerce' ),
+ ),
+ 'maximum_amount' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'Maximum order amount allowed to use the coupon.', 'woocommerce' ),
+ ),
+ 'email_restrictions' => array(
+ 'type' => Type::listOf( Type::nonNull( Type::string() ) ),
+ 'description' => __( 'Email addresses that can use this coupon.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateProduct.php
new file mode 100644
index 00000000000..765a0083c2d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Input/UpdateProduct.php
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Input\Dimensions as DimensionsInput;
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class UpdateProduct {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => 'UpdateProductInput',
+ 'description' => __( 'Data for updating an existing product.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The ID of the product to update.', 'woocommerce' ),
+ ),
+ 'name' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => ProductStatusType::get(),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => ProductTypeType::get(),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The regular price.', 'woocommerce' ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The sale price.', 'woocommerce' ),
+ ),
+ 'manage_stock' => array(
+ 'type' => Type::boolean(),
+ 'description' => __( 'Whether to manage stock.', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => DimensionsInput::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/ObjectWithId.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/ObjectWithId.php
new file mode 100644
index 00000000000..3b0cbb3fb3e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/ObjectWithId.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\InterfaceType;
+use GraphQL\Type\Definition\Type;
+
+class ObjectWithId {
+ private static ?InterfaceType $instance = null;
+
+ public static function get(): InterfaceType {
+ if ( null === self::$instance ) {
+ self::$instance = new InterfaceType(
+ array(
+ 'name' => 'ObjectWithId',
+ 'description' => __( 'An object with a numeric ID.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ 'resolveType' => function ( $value ) {
+ $class = get_class( $value );
+ $map = array(
+ 'Automattic\WooCommerce\Api\Types\Coupons\Coupon' => CouponType::get(),
+ );
+ return $map[ $class ] ?? null;
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/Product.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/Product.php
new file mode 100644
index 00000000000..713d01ba311
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Interfaces/Product.php
@@ -0,0 +1,148 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation as ProductVariationType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ExternalProduct as ExternalProductType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\VariableProduct as VariableProductType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\SimpleProduct as SimpleProductType;
+use GraphQL\Type\Definition\InterfaceType;
+use GraphQL\Type\Definition\Type;
+
+class Product {
+ private static ?InterfaceType $instance = null;
+
+ public static function get(): InterfaceType {
+ if ( null === self::$instance ) {
+ self::$instance = new InterfaceType(
+ array(
+ 'name' => 'Product',
+ 'description' => __( 'A WooCommerce product.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ 'deprecationReason' => 'Use description instead.',
+ ),
+ 'status' => array(
+ 'type' => Type::nonNull( ProductStatusType::get() ),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'raw_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => Type::nonNull( ProductTypeType::get() ),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'raw_product_type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The sale price of the product.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'stock_status' => array(
+ 'type' => Type::nonNull( StockStatusType::get() ),
+ 'description' => __( 'The stock status of the product.', 'woocommerce' ),
+ ),
+ 'raw_stock_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => ProductDimensions::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ 'images' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+ 'description' => __( 'The product images.', 'woocommerce' ),
+ ),
+ 'attributes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+ 'description' => __( 'The product attributes.', 'woocommerce' ),
+ ),
+ 'reviews' => array(
+ 'type' => Type::nonNull( ProductReviewConnectionType::get() ),
+ 'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was created.', 'woocommerce' ),
+ ),
+ 'date_modified' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ 'resolveType' => function ( $value ) {
+ $class = get_class( $value );
+ $map = array(
+ 'Automattic\WooCommerce\Api\Types\Products\ProductVariation' => ProductVariationType::get(),
+ 'Automattic\WooCommerce\Api\Types\Products\ExternalProduct' => ExternalProductType::get(),
+ 'Automattic\WooCommerce\Api\Types\Products\VariableProduct' => VariableProductType::get(),
+ 'Automattic\WooCommerce\Api\Types\Products\SimpleProduct' => SimpleProductType::get(),
+ );
+ return $map[ $class ] ?? null;
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/Coupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/Coupon.php
new file mode 100644
index 00000000000..5828b0f4bb7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/Coupon.php
@@ -0,0 +1,138 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\DiscountType as DiscountTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\CouponStatus as CouponStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\ObjectWithId as ObjectWithIdInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class Coupon {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Coupon',
+ 'description' => __( 'Represents a WooCommerce discount coupon.', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ ObjectWithIdInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'code' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The coupon code.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The coupon description.', 'woocommerce' ),
+ ),
+ 'discount_type' => array(
+ 'type' => Type::nonNull( DiscountTypeType::get() ),
+ 'description' => __( 'The type of discount.', 'woocommerce' ),
+ ),
+ 'raw_discount_type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw discount type as stored in WooCommerce. Useful when discount_type is OTHER (e.g. plugin-added types like recurring_percent or sign_up_fee).', 'woocommerce' ),
+ ),
+ 'amount' => array(
+ 'type' => Type::nonNull( Type::float() ),
+ 'description' => __( 'The discount amount.', 'woocommerce' ),
+ ),
+ 'status' => array(
+ 'type' => Type::nonNull( CouponStatusType::get() ),
+ 'description' => __( 'The coupon status.', 'woocommerce' ),
+ ),
+ 'raw_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the coupon was created.', 'woocommerce' ),
+ ),
+ 'date_modified' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the coupon was last modified.', 'woocommerce' ),
+ ),
+ 'date_expires' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the coupon expires.', 'woocommerce' ),
+ ),
+ 'usage_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The number of times the coupon has been used.', 'woocommerce' ),
+ ),
+ 'individual_use' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the coupon can only be used alone.', 'woocommerce' ),
+ ),
+ 'product_ids' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+ 'description' => __( 'Product IDs the coupon can be applied to.', 'woocommerce' ),
+ ),
+ 'excluded_product_ids' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+ 'description' => __( 'Product IDs excluded from the coupon.', 'woocommerce' ),
+ ),
+ 'usage_limit' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'Maximum number of times the coupon can be used in total.', 'woocommerce' ),
+ ),
+ 'usage_limit_per_user' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'Maximum number of times the coupon can be used per customer.', 'woocommerce' ),
+ ),
+ 'limit_usage_to_x_items' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'Maximum number of items the coupon can be applied to.', 'woocommerce' ),
+ ),
+ 'free_shipping' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the coupon grants free shipping.', 'woocommerce' ),
+ ),
+ 'product_categories' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+ 'description' => __( 'Product category IDs the coupon applies to.', 'woocommerce' ),
+ ),
+ 'excluded_product_categories' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::int() ) ) ),
+ 'description' => __( 'Product category IDs excluded from the coupon.', 'woocommerce' ),
+ ),
+ 'exclude_sale_items' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the coupon excludes items on sale.', 'woocommerce' ),
+ ),
+ 'minimum_amount' => array(
+ 'type' => Type::nonNull( Type::float() ),
+ 'description' => __( 'Minimum order amount required to use the coupon.', 'woocommerce' ),
+ ),
+ 'maximum_amount' => array(
+ 'type' => Type::nonNull( Type::float() ),
+ 'description' => __( 'Maximum order amount allowed to use the coupon.', 'woocommerce' ),
+ ),
+ 'email_restrictions' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::string() ) ) ),
+ 'description' => __( 'Email addresses that can use this coupon.', 'woocommerce' ),
+ ),
+ 'used_by' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::string() ) ) ),
+ 'description' => __( 'Email addresses of customers who have used this coupon.', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/DeleteCouponResult.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/DeleteCouponResult.php
new file mode 100644
index 00000000000..e71fad9b810
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/DeleteCouponResult.php
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class DeleteCouponResult {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'DeleteCouponResult',
+ 'description' => __( 'The result of deleting a coupon.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The ID of the deleted coupon.', 'woocommerce' ),
+ ),
+ 'deleted' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the coupon was permanently deleted.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ExternalProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ExternalProduct.php
new file mode 100644
index 00000000000..c9a3c0e3e05
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ExternalProduct.php
@@ -0,0 +1,146 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ExternalProduct {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ExternalProduct',
+ 'description' => __( 'An external/affiliate product.', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ ProductInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'product_url' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The external product URL.', 'woocommerce' ),
+ ),
+ 'button_text' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The text for the external product button.', 'woocommerce' ),
+ ),
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ 'deprecationReason' => 'Use description instead.',
+ ),
+ 'status' => array(
+ 'type' => Type::nonNull( ProductStatusType::get() ),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'raw_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => Type::nonNull( ProductTypeType::get() ),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'raw_product_type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The sale price of the product.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'stock_status' => array(
+ 'type' => Type::nonNull( StockStatusType::get() ),
+ 'description' => __( 'The stock status of the product.', 'woocommerce' ),
+ ),
+ 'raw_stock_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => ProductDimensions::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ 'images' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+ 'description' => __( 'The product images.', 'woocommerce' ),
+ ),
+ 'attributes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+ 'description' => __( 'The product attributes.', 'woocommerce' ),
+ ),
+ 'reviews' => array(
+ 'type' => Type::nonNull( ProductReviewConnectionType::get() ),
+ 'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was created.', 'woocommerce' ),
+ ),
+ 'date_modified' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductAttribute.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductAttribute.php
new file mode 100644
index 00000000000..d0ed3df49d3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductAttribute.php
@@ -0,0 +1,55 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductAttribute {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductAttribute',
+ 'description' => __( 'A product attribute.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The attribute display name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The attribute taxonomy or key name.', 'woocommerce' ),
+ ),
+ 'options' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( Type::string() ) ) ),
+ 'description' => __( 'The available attribute values.', 'woocommerce' ),
+ ),
+ 'position' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The display order position.', 'woocommerce' ),
+ ),
+ 'visible' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the attribute is visible on the product page.', 'woocommerce' ),
+ ),
+ 'variation' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the attribute is used for variations.', 'woocommerce' ),
+ ),
+ 'is_taxonomy' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ 'description' => __( 'Whether the attribute is a global taxonomy attribute.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductDimensions.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductDimensions.php
new file mode 100644
index 00000000000..78ecacf7e16
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductDimensions.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductDimensions {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductDimensions',
+ 'description' => __( 'Physical dimensions and weight of a product.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'length' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product length.', 'woocommerce' ),
+ ),
+ 'width' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product width.', 'woocommerce' ),
+ ),
+ 'height' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product height.', 'woocommerce' ),
+ ),
+ 'weight' => array(
+ 'type' => Type::float(),
+ 'description' => __( 'The product weight.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductImage.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductImage.php
new file mode 100644
index 00000000000..4131b2975ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductImage.php
@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductImage {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductImage',
+ 'description' => __( 'Represents a product image.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The image attachment ID.', 'woocommerce' ),
+ ),
+ 'url' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The image URL.', 'woocommerce' ),
+ ),
+ 'alt' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The image alt text.', 'woocommerce' ),
+ ),
+ 'position' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The image display position.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductReview.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductReview.php
new file mode 100644
index 00000000000..c1e865ec835
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductReview.php
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductReview {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductReview',
+ 'description' => __( 'Represents a customer review for a product.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The review ID.', 'woocommerce' ),
+ ),
+ 'product_id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The product ID this review belongs to.', 'woocommerce' ),
+ ),
+ 'reviewer' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The reviewer name.', 'woocommerce' ),
+ ),
+ 'review' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The review content.', 'woocommerce' ),
+ ),
+ 'rating' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The review rating (1-5).', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the review was created.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductVariation.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductVariation.php
new file mode 100644
index 00000000000..e1a36ab38c3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/ProductVariation.php
@@ -0,0 +1,147 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\SelectedAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductVariation {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductVariation',
+ 'description' => __( 'A product variation.', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ ProductInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'parent_id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The parent variable product ID.', 'woocommerce' ),
+ ),
+ 'selected_attributes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( SelectedAttribute::get() ) ) ),
+ 'description' => __( 'The selected attribute values for this variation.', 'woocommerce' ),
+ ),
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ 'deprecationReason' => 'Use description instead.',
+ ),
+ 'status' => array(
+ 'type' => Type::nonNull( ProductStatusType::get() ),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'raw_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => Type::nonNull( ProductTypeType::get() ),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'raw_product_type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The sale price of the product.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'stock_status' => array(
+ 'type' => Type::nonNull( StockStatusType::get() ),
+ 'description' => __( 'The stock status of the product.', 'woocommerce' ),
+ ),
+ 'raw_stock_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => ProductDimensions::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ 'images' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+ 'description' => __( 'The product images.', 'woocommerce' ),
+ ),
+ 'attributes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+ 'description' => __( 'The product attributes.', 'woocommerce' ),
+ ),
+ 'reviews' => array(
+ 'type' => Type::nonNull( ProductReviewConnectionType::get() ),
+ 'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was created.', 'woocommerce' ),
+ ),
+ 'date_modified' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SelectedAttribute.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SelectedAttribute.php
new file mode 100644
index 00000000000..2bec5ace603
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SelectedAttribute.php
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class SelectedAttribute {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'SelectedAttribute',
+ 'description' => __( 'A selected attribute value on a product variation.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The attribute name or slug.', 'woocommerce' ),
+ ),
+ 'value' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The selected attribute value.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SimpleProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SimpleProduct.php
new file mode 100644
index 00000000000..00d45b97576
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/SimpleProduct.php
@@ -0,0 +1,138 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class SimpleProduct {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'SimpleProduct',
+ 'description' => __( 'A simple WooCommerce product.', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ ProductInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ 'deprecationReason' => 'Use description instead.',
+ ),
+ 'status' => array(
+ 'type' => Type::nonNull( ProductStatusType::get() ),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'raw_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => Type::nonNull( ProductTypeType::get() ),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'raw_product_type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The sale price of the product.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'stock_status' => array(
+ 'type' => Type::nonNull( StockStatusType::get() ),
+ 'description' => __( 'The stock status of the product.', 'woocommerce' ),
+ ),
+ 'raw_stock_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => ProductDimensions::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ 'images' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+ 'description' => __( 'The product images.', 'woocommerce' ),
+ ),
+ 'attributes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+ 'description' => __( 'The product attributes.', 'woocommerce' ),
+ ),
+ 'reviews' => array(
+ 'type' => Type::nonNull( ProductReviewConnectionType::get() ),
+ 'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was created.', 'woocommerce' ),
+ ),
+ 'date_modified' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/VariableProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/VariableProduct.php
new file mode 100644
index 00000000000..c9519c8e7a1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Output/VariableProduct.php
@@ -0,0 +1,169 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductVariationConnection as ProductVariationConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductStatus as ProductStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\ProductType as ProductTypeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Enums\StockStatus as StockStatusType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductDimensions;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductImage;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductAttribute;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination\ProductReviewConnection as ProductReviewConnectionType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars\DateTime as DateTimeType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductInterface;
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class VariableProduct {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'VariableProduct',
+ 'description' => __( 'A variable product with variations.', 'woocommerce' ),
+ 'interfaces' => fn() => array(
+ ProductInterface::get(),
+ ),
+ 'fields' => fn() => array(
+ 'variations' => array(
+ 'type' => Type::nonNull( ProductVariationConnectionType::get() ),
+ 'description' => __( 'The product variations.', 'woocommerce' ),
+ 'args' => array(
+ 'first' => array(
+ 'type' => Type::int(),
+ 'defaultValue' => null,
+ 'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
+ ),
+ 'last' => array(
+ 'type' => Type::int(),
+ 'defaultValue' => null,
+ 'description' => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
+ ),
+ 'after' => array(
+ 'type' => Type::string(),
+ 'defaultValue' => null,
+ 'description' => __( 'Return results after this cursor.', 'woocommerce' ),
+ ),
+ 'before' => array(
+ 'type' => Type::string(),
+ 'defaultValue' => null,
+ 'description' => __( 'Return results before this cursor.', 'woocommerce' ),
+ ),
+ ),
+ 'complexity' => Utils::complexity_from_pagination( ... ),
+ 'resolve' => fn( $parent, array $args ): Connection => Utils::translate_exceptions( fn() => $parent->variations->slice( $args ) ),
+ ),
+ 'name' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product name.', 'woocommerce' ),
+ ),
+ 'slug' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The product slug.', 'woocommerce' ),
+ ),
+ 'sku' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The product SKU.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The full product description.', 'woocommerce' ),
+ ),
+ 'short_description' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The short product description.', 'woocommerce' ),
+ 'deprecationReason' => 'Use description instead.',
+ ),
+ 'status' => array(
+ 'type' => Type::nonNull( ProductStatusType::get() ),
+ 'description' => __( 'The product status.', 'woocommerce' ),
+ ),
+ 'raw_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw status as stored in WordPress. Useful when status is OTHER (e.g. plugin-added post statuses).', 'woocommerce' ),
+ ),
+ 'product_type' => array(
+ 'type' => Type::nonNull( ProductTypeType::get() ),
+ 'description' => __( 'The product type.', 'woocommerce' ),
+ ),
+ 'raw_product_type' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw product type as stored in WooCommerce. Useful when product_type is OTHER (e.g. plugin-added types like subscription, bundle).', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The regular price of the product. Null when not set.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'Whether to apply currency formatting.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'sale_price' => array(
+ 'type' => Type::string(),
+ 'description' => __( 'The sale price of the product.', 'woocommerce' ),
+ 'args' => array(
+ 'formatted' => array(
+ 'type' => Type::boolean(),
+ 'defaultValue' => true,
+ 'description' => __( 'When true, returns price with currency symbol.', 'woocommerce' ),
+ ),
+ ),
+ ),
+ 'stock_status' => array(
+ 'type' => Type::nonNull( StockStatusType::get() ),
+ 'description' => __( 'The stock status of the product.', 'woocommerce' ),
+ ),
+ 'raw_stock_status' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ 'description' => __( 'The raw stock status as stored in WooCommerce. Useful when stock_status is OTHER (e.g. plugin-added statuses).', 'woocommerce' ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => Type::int(),
+ 'description' => __( 'The number of items in stock.', 'woocommerce' ),
+ ),
+ 'dimensions' => array(
+ 'type' => ProductDimensions::get(),
+ 'description' => __( 'The product dimensions.', 'woocommerce' ),
+ ),
+ 'images' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductImage::get() ) ) ),
+ 'description' => __( 'The product images.', 'woocommerce' ),
+ ),
+ 'attributes' => array(
+ 'type' => Type::nonNull( Type::listOf( Type::nonNull( ProductAttribute::get() ) ) ),
+ 'description' => __( 'The product attributes.', 'woocommerce' ),
+ ),
+ 'reviews' => array(
+ 'type' => Type::nonNull( ProductReviewConnectionType::get() ),
+ 'description' => __( 'Customer reviews for this product.', 'woocommerce' ),
+ ),
+ 'date_created' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was created.', 'woocommerce' ),
+ ),
+ 'date_modified' => array(
+ 'type' => DateTimeType::get(),
+ 'description' => __( 'The date the product was last modified.', 'woocommerce' ),
+ ),
+ 'id' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ 'description' => __( 'The unique numeric identifier.', 'woocommerce' ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponConnection.php
new file mode 100644
index 00000000000..0b26171b4eb
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CouponConnection {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'CouponConnection',
+ 'description' => __( 'A connection to a list of Coupon items.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'edges' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ CouponEdge::get()
+ )
+ )
+ ),
+ ),
+ 'nodes' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ CouponType::get()
+ )
+ )
+ ),
+ ),
+ 'page_info' => array(
+ 'type' => Type::nonNull( PageInfo::get() ),
+ ),
+ 'total_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponEdge.php
new file mode 100644
index 00000000000..71435aec5de
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/CouponEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon as CouponType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class CouponEdge {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'CouponEdge',
+ 'fields' => fn() => array(
+ 'cursor' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ ),
+ 'node' => array(
+ 'type' => Type::nonNull( CouponType::get() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/PageInfo.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/PageInfo.php
new file mode 100644
index 00000000000..821ed70c2bf
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/PageInfo.php
@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class PageInfo {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'PageInfo',
+ 'fields' => array(
+ 'has_next_page' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ ),
+ 'has_previous_page' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ ),
+ 'start_cursor' => array(
+ 'type' => Type::string(),
+ ),
+ 'end_cursor' => array(
+ 'type' => Type::string(),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductConnection.php
new file mode 100644
index 00000000000..c37c757f2f3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductConnection {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductConnection',
+ 'description' => __( 'A connection to a list of Product items.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'edges' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ ProductEdge::get()
+ )
+ )
+ ),
+ ),
+ 'nodes' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ ProductType::get()
+ )
+ )
+ ),
+ ),
+ 'page_info' => array(
+ 'type' => Type::nonNull( PageInfo::get() ),
+ ),
+ 'total_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductEdge.php
new file mode 100644
index 00000000000..097ad21a2b1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Interfaces\Product as ProductType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductEdge {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductEdge',
+ 'fields' => fn() => array(
+ 'cursor' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ ),
+ 'node' => array(
+ 'type' => Type::nonNull( ProductType::get() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewConnection.php
new file mode 100644
index 00000000000..86e38a7c4c1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductReview as ProductReviewType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductReviewConnection {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductReviewConnection',
+ 'description' => __( 'A connection to a list of ProductReview items.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'edges' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ ProductReviewEdge::get()
+ )
+ )
+ ),
+ ),
+ 'nodes' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ ProductReviewType::get()
+ )
+ )
+ ),
+ ),
+ 'page_info' => array(
+ 'type' => Type::nonNull( PageInfo::get() ),
+ ),
+ 'total_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewEdge.php
new file mode 100644
index 00000000000..ed955e17b2c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductReviewEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductReview as ProductReviewType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductReviewEdge {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductReviewEdge',
+ 'fields' => fn() => array(
+ 'cursor' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ ),
+ 'node' => array(
+ 'type' => Type::nonNull( ProductReviewType::get() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationConnection.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationConnection.php
new file mode 100644
index 00000000000..3cfd78864d5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationConnection.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation as ProductVariationType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductVariationConnection {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductVariationConnection',
+ 'description' => __( 'A connection to a list of ProductVariation items.', 'woocommerce' ),
+ 'fields' => fn() => array(
+ 'edges' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ ProductVariationEdge::get()
+ )
+ )
+ ),
+ ),
+ 'nodes' => array(
+ 'type' => Type::nonNull(
+ Type::listOf(
+ Type::nonNull(
+ ProductVariationType::get()
+ )
+ )
+ ),
+ ),
+ 'page_info' => array(
+ 'type' => Type::nonNull( PageInfo::get() ),
+ ),
+ 'total_count' => array(
+ 'type' => Type::nonNull( Type::int() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationEdge.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationEdge.php
new file mode 100644
index 00000000000..7f87da82f72
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Pagination/ProductVariationEdge.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Pagination;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation as ProductVariationType;
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class ProductVariationEdge {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'ProductVariationEdge',
+ 'fields' => fn() => array(
+ 'cursor' => array(
+ 'type' => Type::nonNull( Type::string() ),
+ ),
+ 'node' => array(
+ 'type' => Type::nonNull( ProductVariationType::get() ),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Scalars/DateTime.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Scalars/DateTime.php
new file mode 100644
index 00000000000..4bc6c3ea798
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLTypes/Scalars/DateTime.php
@@ -0,0 +1,45 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Scalars;
+
+use Automattic\WooCommerce\Api\Scalars\DateTime as DateTimeScalar;
+use GraphQL\Type\Definition\CustomScalarType;
+
+class DateTime {
+ private static ?CustomScalarType $instance = null;
+
+ public static function get(): CustomScalarType {
+ if ( null === self::$instance ) {
+ self::$instance = new CustomScalarType(
+ array(
+ 'name' => 'DateTime',
+ 'description' => __( 'An ISO 8601 encoded date and time string.', 'woocommerce' ),
+ 'serialize' => fn( $value ) => DateTimeScalar::serialize( $value ),
+ 'parseValue' => function ( $value ) {
+ try {
+ return DateTimeScalar::parse( $value );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \GraphQL\Error\Error( $e->getMessage() );
+ }
+ },
+ 'parseLiteral' => function ( $value_node, ?array $variables = null ) {
+ if ( $value_node instanceof \GraphQL\Language\AST\StringValueNode ) {
+ try {
+ return DateTimeScalar::parse( $value_node->value );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \GraphQL\Error\Error( $e->getMessage() );
+ }
+ }
+ throw new \GraphQL\Error\Error(
+ 'DateTime must be a string, got: ' . $value_node->kind
+ );
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootMutationType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootMutationType.php
new file mode 100644
index 00000000000..ce9b639d5ec
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootMutationType.php
@@ -0,0 +1,37 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\CreateProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\UpdateProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\DeleteProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\DeleteCoupon;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\CreateCoupon;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLMutations\UpdateCoupon;
+use GraphQL\Type\Definition\ObjectType;
+
+class RootMutationType {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Mutation',
+ 'fields' => fn() => array(
+ 'createProduct' => CreateProduct::get_field_definition(),
+ 'updateProduct' => UpdateProduct::get_field_definition(),
+ 'deleteProduct' => DeleteProduct::get_field_definition(),
+ 'deleteCoupon' => DeleteCoupon::get_field_definition(),
+ 'createCoupon' => CreateCoupon::get_field_definition(),
+ 'updateCoupon' => UpdateCoupon::get_field_definition(),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
new file mode 100644
index 00000000000..b2a8dde5585
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
@@ -0,0 +1,33 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListProducts;
+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 GraphQL\Type\Definition\ObjectType;
+
+class RootQueryType {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ 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(),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/TypeRegistry.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/TypeRegistry.php
new file mode 100644
index 00000000000..c8ae533c2a3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/TypeRegistry.php
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ProductVariation;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\ExternalProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\VariableProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\SimpleProduct;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLTypes\Output\Coupon;
+
+class TypeRegistry {
+ /**
+ * Return all concrete types that implement interfaces.
+ *
+ * Pass this to the Schema 'types' config so that inline fragments
+ * (e.g. `... on VariableProduct`) are resolvable.
+ *
+ * @return array
+ */
+ public static function get_interface_implementors(): array {
+ return array(
+ ProductVariation::get(),
+ ExternalProduct::get(),
+ VariableProduct::get(),
+ SimpleProduct::get(),
+ Coupon::get(),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
new file mode 100644
index 00000000000..e6f606063e3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -0,0 +1 @@
+2026-04-21T07:16:03+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
new file mode 100644
index 00000000000..36b96d0138b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -0,0 +1,1699 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api\DesignTime\Scripts;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
+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\Name;
+use Automattic\WooCommerce\Api\Attributes\Parameter;
+use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use Automattic\WooCommerce\Api\Attributes\ReturnType;
+use Automattic\WooCommerce\Api\Attributes\ScalarType;
+use Automattic\WooCommerce\Api\Attributes\Unroll;
+
+/**
+ * Scans the public API classes and generates the GraphQL schema and resolver code.
+ */
+class ApiBuilder {
+ private const API_DIR = __DIR__ . '/../../../../Api';
+ private const AUTOGENERATED_DIR = __DIR__ . '/../../Autogenerated';
+ private const TEMPLATES_DIR = __DIR__ . '/../Templates';
+ private const API_NAMESPACE = 'Automattic\\WooCommerce\\Api';
+ private const AUTOGENERATED_NAMESPACE = 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated';
+
+ /** @var array<string, array{class: \ReflectionClass|\ReflectionEnum, kind: string, ignored: bool}> */
+ private array $classes = array();
+
+ /** @var array<string, string> Map of PHP FQCN => GraphQL name */
+ private array $graphql_names = array();
+
+ /** @var string[] Errors collected during validation */
+ private array $errors = array();
+
+ /** @var string[] Warnings collected during build */
+ private array $warnings = array();
+
+ /** @var array Discovered connections: ['node_type' => FQCN, 'source' => string] */
+ private array $connections = array();
+
+ /** @var array<string, string[]> Map of interface trait FQCN => list of output type FQCNs that use it */
+ private array $interface_implementors = array();
+
+ // Counters for summary.
+ private int $query_count = 0;
+ private int $mutation_count = 0;
+ private int $type_count = 0;
+ private int $input_type_count = 0;
+ private int $enum_count = 0;
+ private int $scalar_count = 0;
+ private int $interface_count = 0;
+
+ public function build( bool $skip_linter = false ): void {
+ echo "Scanning src/Api/ for code API classes...\n";
+
+ $this->discover();
+ $this->validate();
+
+ if ( ! empty( $this->errors ) ) {
+ fwrite( STDERR, "Build failed with errors:\n" );
+ foreach ( $this->errors as $error ) {
+ fwrite( STDERR, " - {$error}\n" );
+ }
+ exit( 1 );
+ }
+
+ $this->wipe_autogenerated();
+ $this->create_directory_structure();
+ $this->generate();
+ if ( ! $skip_linter ) {
+ echo "Applying linter to generated files...\n";
+ $this->format_with_phpcbf( self::AUTOGENERATED_DIR );
+ }
+ $this->write_timestamp();
+
+ // Regenerate autoloader.
+ $wc_dir = realpath( __DIR__ . '/../../../../..' );
+ echo "Regenerating autoloader...\n";
+ exec( 'composer dump-autoload --working-dir=' . escapeshellarg( $wc_dir ) . ' 2>&1', $output, $code );
+ if ( $code !== 0 ) {
+ echo 'Warning: composer dump-autoload failed: ' . implode( "\n", $output ) . "\n";
+ }
+
+ // Print summary.
+ echo "\n=== Build Complete ===\n";
+ echo " Queries: {$this->query_count}\n";
+ echo " Mutations: {$this->mutation_count}\n";
+ echo " Types: {$this->type_count}\n";
+ echo " Input Types: {$this->input_type_count}\n";
+ echo " Enums: {$this->enum_count}\n";
+ echo " Scalars: {$this->scalar_count}\n";
+ echo " Interfaces: {$this->interface_count}\n";
+ echo ' Connections: ' . count( $this->connections ) . "\n";
+
+ if ( ! empty( $this->warnings ) ) {
+ echo "\nWarnings:\n";
+ foreach ( $this->warnings as $warning ) {
+ echo " - {$warning}\n";
+ }
+ }
+ }
+
+ // ========================================================================
+ // Discovery
+ // ========================================================================
+
+ private function discover(): void {
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( self::API_DIR, \FilesystemIterator::SKIP_DOTS )
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( $file->getExtension() !== 'php' ) {
+ continue;
+ }
+
+ $fqcn = $this->file_to_fqcn( $file->getPathname() );
+ if ( $fqcn === null ) {
+ continue;
+ }
+
+ $kind = $this->classify_by_namespace( $fqcn );
+ if ( $kind === null || $kind === 'attribute' || $kind === 'exception' ) {
+ continue;
+ }
+
+ try {
+ if ( enum_exists( $fqcn ) ) {
+ $ref = new \ReflectionEnum( $fqcn );
+ } else {
+ $ref = new \ReflectionClass( $fqcn );
+ }
+ } catch ( \ReflectionException $e ) {
+ $this->warnings[] = "Could not reflect {$fqcn}: {$e->getMessage()}";
+ continue;
+ }
+
+ $ignored = ! empty( $ref->getAttributes( Ignore::class ) );
+
+ // Abstract classes are base classes, not concrete API
+ // endpoints or types — skip them automatically.
+ if ( ! $ignored && $ref instanceof \ReflectionClass && $ref->isAbstract() ) {
+ $ignored = true;
+ }
+
+ // Traits outside Interfaces/ are helper mixins (e.g.
+ // TracksProvidedFields), not concrete types — skip them so they
+ // don't end up emitted as empty-field InputObjectTypes. Traits
+ // in Interfaces/ model GraphQL interfaces and are legitimate.
+ if ( ! $ignored && $ref instanceof \ReflectionClass && $ref->isTrait() && 'interface' !== $kind ) {
+ $ignored = true;
+ }
+
+ $this->classes[ $fqcn ] = array(
+ 'class' => $ref,
+ 'kind' => $kind,
+ 'ignored' => $ignored,
+ );
+
+ // Compute GraphQL name.
+ $name_attr = $ref->getAttributes( Name::class );
+ $graphql_name = ! empty( $name_attr )
+ ? $name_attr[0]->newInstance()->name
+ : $ref->getShortName();
+
+ $this->graphql_names[ $fqcn ] = $graphql_name;
+ }
+
+ echo ' Found ' . count( $this->classes ) . " classes.\n";
+ }
+
+ private function file_to_fqcn( string $filepath ): ?string {
+ $rel = str_replace( realpath( self::API_DIR ) . '/', '', realpath( $filepath ) );
+ $rel = str_replace( '.php', '', $rel );
+ $rel = str_replace( '/', '\\', $rel );
+ return self::API_NAMESPACE . '\\' . $rel;
+ }
+
+ private function classify_by_namespace( string $fqcn ): ?string {
+ $relative = substr( $fqcn, strlen( self::API_NAMESPACE ) + 1 );
+ $parts = explode( '\\', $relative );
+ $top_dir = $parts[0];
+
+ return match ( $top_dir ) {
+ 'Queries' => 'query',
+ 'Mutations' => 'mutation',
+ 'Types' => 'type',
+ 'InputTypes' => 'input_type',
+ 'Enums' => 'enum',
+ 'Interfaces' => 'interface',
+ 'Scalars' => 'scalar',
+ 'Pagination' => 'pagination',
+ 'Attributes' => 'attribute',
+ default => $fqcn === self::API_NAMESPACE . '\\ApiException' ? 'exception' : null,
+ };
+ }
+
+ // ========================================================================
+ // Validation
+ // ========================================================================
+
+ private function validate(): void {
+ foreach ( $this->classes as $fqcn => $info ) {
+ if ( $info['ignored'] ) {
+ continue;
+ }
+
+ $ref = $info['class'];
+ $kind = $info['kind'];
+
+ match ( $kind ) {
+ 'query', 'mutation' => $this->validate_command( $fqcn, $ref ),
+ 'type' => $this->validate_output_type( $fqcn, $ref ),
+ 'input_type' => $this->validate_input_type( $fqcn, $ref ),
+ 'enum' => $this->validate_enum( $fqcn, $ref ),
+ 'scalar' => $this->validate_scalar( $fqcn, $ref ),
+ 'interface' => $this->validate_interface( $fqcn, $ref ),
+ default => null,
+ };
+ }
+ }
+
+ private function validate_command( string $fqcn, \ReflectionClass $ref ): void {
+ // Must have execute method.
+ if ( ! $ref->hasMethod( 'execute' ) ) {
+ $this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" must have an execute method.";
+ return;
+ }
+
+ // Authorization check: must have RequiredCapability, PublicAccess, or a non-ignored authorize() method.
+ // A direct attribute on the class takes precedence over inherited ones.
+ $auth = $this->resolve_authorization( $ref );
+ $has_authorize = $ref->hasMethod( 'authorize' )
+ && empty( $ref->getMethod( 'authorize' )->getAttributes( Ignore::class ) );
+
+ if ( null === $auth['error'] && empty( $auth['caps'] ) && ! $auth['public'] && ! $has_authorize ) {
+ $this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" has no RequiredCapability attribute (directly or inherited), no PublicAccess attribute, and no authorize() method.";
+ }
+
+ if ( null !== $auth['error'] ) {
+ $this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" {$auth['error']}";
+ }
+
+ $this->check_for_ignored_auth_attribute( $fqcn, $ref );
+
+ // ReturnType attribute validation.
+ $execute_method = $ref->getMethod( 'execute' );
+ $return_type = $execute_method->getReturnType();
+ $return_type_name = $return_type instanceof \ReflectionNamedType ? $return_type->getName() : 'mixed';
+ $return_type_attr = $execute_method->getAttributes( ReturnType::class );
+
+ if ( 'object' === $return_type_name && empty( $return_type_attr ) ) {
+ $this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" returns 'object' but has no #[ReturnType] attribute on execute().";
+ }
+
+ if ( ! empty( $return_type_attr ) && 'object' !== $return_type_name ) {
+ $this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" has #[ReturnType] on execute() but does not return 'object'.";
+ }
+
+ if ( ! empty( $return_type_attr ) ) {
+ $rt_class = $return_type_attr[0]->newInstance()->type;
+ $rt_info = $this->get_class_info( $rt_class );
+ if ( null === $rt_info || 'interface' !== $rt_info['kind'] ) {
+ $this->errors[] = "Query/Mutation \"{$ref->getShortName()}\": #[ReturnType] references '{$rt_class}' which is not a known interface.";
+ }
+ }
+ }
+
+ /**
+ * Warn when a class declares both an authorization attribute and an
+ * authorize() method directly on itself without opting into composition
+ * via the $_preauthorized infrastructure parameter. In that configuration
+ * the attribute is silently ignored, which is almost always a bug.
+ *
+ * Inherited attributes paired with a direct authorize() are intentional
+ * (the documented override mechanism) and are not flagged.
+ *
+ * @param string $fqcn The class fully-qualified name.
+ * @param \ReflectionClass $ref The reflection of the class.
+ */
+ private function check_for_ignored_auth_attribute( string $fqcn, \ReflectionClass $ref ): void {
+ $has_direct_cap = ! empty( $ref->getAttributes( RequiredCapability::class ) );
+ $has_direct_public = ! empty( $ref->getAttributes( PublicAccess::class ) );
+
+ if ( ! $has_direct_cap && ! $has_direct_public ) {
+ return;
+ }
+
+ if ( ! $ref->hasMethod( 'authorize' ) ) {
+ return;
+ }
+
+ $authorize_method = $ref->getMethod( 'authorize' );
+ if ( ! empty( $authorize_method->getAttributes( Ignore::class ) ) ) {
+ return;
+ }
+
+ if ( $authorize_method->getDeclaringClass()->getName() !== $fqcn ) {
+ // authorize() is inherited; overriding the attribute is intentional.
+ return;
+ }
+
+ foreach ( $authorize_method->getParameters() as $p ) {
+ if ( '_preauthorized' === $p->getName() ) {
+ // Developer opted into composition.
+ return;
+ }
+ }
+
+ $this->warnings[] = sprintf(
+ 'Query/Mutation "%s" declares an authorization attribute and an authorize() method on the same class; the attribute has no effect. Add a `bool $_preauthorized` parameter to authorize() to compose the two.',
+ $ref->getShortName()
+ );
+ }
+
+ private function validate_output_type( string $fqcn, \ReflectionClass $ref ): void {
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+ $this->validate_property_type( $prop, 'output', $ref->getShortName() );
+ }
+ }
+
+ private function validate_input_type( string $fqcn, \ReflectionClass $ref ): void {
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+ $this->validate_property_type( $prop, 'input', $ref->getShortName() );
+ }
+ }
+
+ private function validate_property_type( \ReflectionProperty $prop, string $context, string $class_name ): void {
+ $type = $prop->getType();
+ if ( $type === null ) {
+ $this->errors[] = "Property \"{$class_name}::\${$prop->getName()}\" must have a type declaration.";
+ return;
+ }
+
+ if ( $type instanceof \ReflectionNamedType && $type->getName() === 'array' ) {
+ if ( empty( $prop->getAttributes( ArrayOf::class ) ) && empty( $prop->getAttributes( ConnectionOf::class ) ) ) {
+ $this->errors[] = "Property \"{$class_name}::\${$prop->getName()}\" is typed as array but has no #[ArrayOf] attribute.";
+ }
+ }
+ }
+
+ private function validate_enum( string $fqcn, \ReflectionEnum $ref ): void {
+ if ( ! $ref->isBacked() ) {
+ $this->errors[] = "Enum \"{$ref->getShortName()}\" must be a backed enum (string or int).";
+ }
+ }
+
+ private function validate_scalar( string $fqcn, \ReflectionClass $ref ): void {
+ if ( ! $ref->hasMethod( 'serialize' ) || ! $ref->hasMethod( 'parse' ) ) {
+ $this->errors[] = "Scalar \"{$ref->getShortName()}\" must have static serialize and parse methods.";
+ }
+ }
+
+ private function validate_interface( string $fqcn, \ReflectionClass $ref ): void {
+ if ( ! $ref->isTrait() ) {
+ $this->errors[] = "Interface \"{$ref->getShortName()}\" must be a trait.";
+ return;
+ }
+
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+ $this->validate_property_type( $prop, 'output', $ref->getShortName() );
+ }
+ }
+
+ /**
+ * Resolve the authorization strategy for a query/mutation class.
+ *
+ * A direct attribute on the class itself takes precedence over inherited ones.
+ * Having both RequiredCapability and PublicAccess on the same class is an error,
+ * but a derived class may override an inherited attribute of the other kind.
+ *
+ * @param \ReflectionClass $ref The class to inspect.
+ * @return array{caps: string[], public: bool, error: ?string}
+ */
+ private function resolve_authorization( \ReflectionClass $ref ): array {
+ $direct_caps = array();
+ $direct_public = ! empty( $ref->getAttributes( PublicAccess::class ) );
+
+ foreach ( $ref->getAttributes( RequiredCapability::class ) as $attr ) {
+ $direct_caps[] = $attr->newInstance()->capability;
+ }
+
+ // Same class has both — always an error.
+ if ( ! empty( $direct_caps ) && $direct_public ) {
+ return array(
+ 'caps' => array(),
+ 'public' => false,
+ 'error' => 'cannot have both RequiredCapability and PublicAccess.',
+ );
+ }
+
+ // Direct attribute found — use it, ignore inherited.
+ if ( ! empty( $direct_caps ) || $direct_public ) {
+ return array(
+ 'caps' => array_unique( $direct_caps ),
+ 'public' => $direct_public,
+ 'error' => null,
+ );
+ }
+
+ // No direct attribute — inherit from parents, traits, and interfaces.
+ $inherited_caps = array();
+ $inherited_public = false;
+ $sources = array_merge(
+ $ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
+ $ref->getTraits(),
+ $ref->getInterfaces(),
+ );
+
+ foreach ( $sources as $source ) {
+ foreach ( $source->getAttributes( RequiredCapability::class ) as $attr ) {
+ $inherited_caps[] = $attr->newInstance()->capability;
+ }
+ if ( ! empty( $source->getAttributes( PublicAccess::class ) ) ) {
+ $inherited_public = true;
+ }
+ }
+
+ return array(
+ 'caps' => array_unique( $inherited_caps ),
+ 'public' => $inherited_public,
+ 'error' => null,
+ );
+ }
+
+ // ========================================================================
+ // Generation
+ // ========================================================================
+
+ private function wipe_autogenerated(): void {
+ if ( is_dir( self::AUTOGENERATED_DIR ) ) {
+ $this->rmdir_recursive( self::AUTOGENERATED_DIR );
+ }
+ }
+
+ private function create_directory_structure(): void {
+ $dirs = array(
+ self::AUTOGENERATED_DIR,
+ self::AUTOGENERATED_DIR . '/GraphQLTypes/Output',
+ self::AUTOGENERATED_DIR . '/GraphQLTypes/Input',
+ self::AUTOGENERATED_DIR . '/GraphQLTypes/Enums',
+ self::AUTOGENERATED_DIR . '/GraphQLTypes/Interfaces',
+ self::AUTOGENERATED_DIR . '/GraphQLTypes/Scalars',
+ self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination',
+ self::AUTOGENERATED_DIR . '/GraphQLQueries',
+ self::AUTOGENERATED_DIR . '/GraphQLMutations',
+ );
+
+ foreach ( $dirs as $dir ) {
+ if ( ! is_dir( $dir ) ) {
+ mkdir( $dir, 0755, true );
+ }
+ }
+ }
+
+ private function generate(): void {
+ $queries = array();
+ $mutations = array();
+ $interfaces = array();
+ $output_types = array();
+
+ // Collect interface trait FQCNs for lookup.
+ $interface_fqcns = array();
+ foreach ( $this->classes as $fqcn => $info ) {
+ if ( ! $info['ignored'] && $info['kind'] === 'interface' ) {
+ $interface_fqcns[ $fqcn ] = true;
+ }
+ }
+
+ // Scan output types to build interface_implementors map.
+ foreach ( $this->classes as $fqcn => $info ) {
+ if ( $info['ignored'] || $info['kind'] !== 'type' ) {
+ continue;
+ }
+ foreach ( $info['class']->getTraits() as $trait ) {
+ $trait_fqcn = $trait->getName();
+ if ( isset( $interface_fqcns[ $trait_fqcn ] ) ) {
+ $this->interface_implementors[ $trait_fqcn ][] = $fqcn;
+ }
+ }
+ }
+
+ foreach ( $this->classes as $fqcn => $info ) {
+ if ( $info['ignored'] || $info['kind'] === 'pagination' ) {
+ continue;
+ }
+
+ $ref = $info['class'];
+ $kind = $info['kind'];
+
+ match ( $kind ) {
+ 'enum' => $this->generate_enum( $fqcn, $ref ),
+ 'scalar' => $this->generate_scalar( $fqcn, $ref ),
+ 'interface' => $this->generate_interface( $fqcn, $ref ),
+ 'type' => $this->generate_output_type( $fqcn, $ref ),
+ 'input_type' => $this->generate_input_type( $fqcn, $ref ),
+ 'query' => $queries[ $fqcn ] = $ref,
+ 'mutation' => $mutations[ $fqcn ] = $ref,
+ default => null,
+ };
+ }
+
+ // Pre-scan connections from queries/mutations (ConnectionOf on execute methods).
+ $this->discover_connections( $queries, $mutations );
+
+ // Generate connections (PageInfo first since connections reference it).
+ $this->generate_page_info();
+ foreach ( $this->connections as $conn ) {
+ $this->generate_connection( $conn['node_type'] );
+ }
+
+ // Generate resolvers.
+ foreach ( $queries as $fqcn => $ref ) {
+ $this->generate_resolver( $fqcn, $ref, 'query' );
+ }
+ foreach ( $mutations as $fqcn => $ref ) {
+ $this->generate_resolver( $fqcn, $ref, 'mutation' );
+ }
+
+ // Generate root types and type registry.
+ $this->generate_root_query_type( $queries );
+ $this->generate_root_mutation_type( $mutations );
+ $this->generate_type_registry();
+ }
+
+ private function discover_connections( array $queries, array $mutations ): void {
+ foreach ( array_merge( $queries, $mutations ) as $fqcn => $ref ) {
+ if ( ! $ref->hasMethod( 'execute' ) ) {
+ continue;
+ }
+ $method = $ref->getMethod( 'execute' );
+ $conn_attr = $method->getAttributes( ConnectionOf::class );
+ if ( ! empty( $conn_attr ) ) {
+ $node_type = $conn_attr[0]->newInstance()->type;
+ $this->connections[ $node_type ] = array(
+ 'node_type' => $node_type,
+ 'source' => $ref->getShortName() . '::execute()',
+ );
+ }
+ }
+ }
+
+ // ------ Enum ------
+
+ private function generate_enum( string $fqcn, \ReflectionEnum $ref ): void {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $enum_alias = $ref->getShortName() . 'Enum';
+
+ $values = array();
+ foreach ( $ref->getCases() as $case ) {
+ $case_name_attr = $case->getAttributes( Name::class );
+ $gql_case_name = ! empty( $case_name_attr )
+ ? $case_name_attr[0]->newInstance()->name
+ : $this->to_screaming_snake_case( $case->getName() );
+
+ $deprecation = $case->getAttributes( Deprecated::class );
+
+ $values[] = array(
+ 'graphql_name' => $gql_case_name,
+ 'case_name' => $case->getName(),
+ 'description' => $this->get_description( $case ),
+ 'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
+ );
+ }
+
+ $code = $this->render_template(
+ 'EnumTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Enums',
+ 'class_name' => $ref->getShortName(),
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'enum_fqcn' => $fqcn,
+ 'enum_alias' => $enum_alias,
+ 'values' => $values,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Enums/' . $ref->getShortName() . '.php';
+ file_put_contents( $path, $code );
+ ++$this->enum_count;
+ }
+
+ // ------ Scalar ------
+
+ private function generate_scalar( string $fqcn, \ReflectionClass $ref ): void {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $scalar_alias = $ref->getShortName() . 'Scalar';
+
+ $code = $this->render_template(
+ 'ScalarTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Scalars',
+ 'class_name' => $ref->getShortName(),
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'scalar_fqcn' => $fqcn,
+ 'scalar_alias' => $scalar_alias,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Scalars/' . $ref->getShortName() . '.php';
+ file_put_contents( $path, $code );
+ ++$this->scalar_count;
+ }
+
+ // ------ Interface ------
+
+ private function generate_interface( string $fqcn, \ReflectionClass $ref ): void {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $use_stmts = array();
+ $fields = array();
+
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+
+ $field = $this->build_field_definition( $prop, 'output', $use_stmts );
+ if ( $field !== null ) {
+ $fields[] = $field;
+ }
+ }
+
+ // Build resolveType map: PHP FQCN => generated ObjectType class alias.
+ $type_map = array();
+ foreach ( $this->interface_implementors[ $fqcn ] ?? array() as $impl_fqcn ) {
+ $impl_short = ( new \ReflectionClass( $impl_fqcn ) )->getShortName();
+ $alias = $impl_short . 'Type';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$impl_short} as {$alias}";
+ $type_map[] = array(
+ 'fqcn' => $impl_fqcn,
+ 'alias' => $alias,
+ );
+ }
+
+ $code = $this->render_template(
+ 'InterfaceTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Interfaces',
+ 'class_name' => $ref->getShortName(),
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'use_statements' => array_unique( $use_stmts ),
+ 'fields' => $fields,
+ 'type_map' => $type_map,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Interfaces/' . $ref->getShortName() . '.php';
+ file_put_contents( $path, $code );
+ ++$this->interface_count;
+ }
+
+ // ------ Output Type ------
+
+ private function generate_output_type( string $fqcn, \ReflectionClass $ref ): void {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $use_stmts = array();
+ $interfaces = array();
+ $fields = array();
+
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+
+ $field = $this->build_field_definition( $prop, 'output', $use_stmts );
+ if ( $field !== null ) {
+ $fields[] = $field;
+ }
+ }
+
+ // Wire interfaces: check if any traits on this class are discovered interfaces.
+ foreach ( $ref->getTraits() as $trait ) {
+ $trait_fqcn = $trait->getName();
+ $trait_info = $this->get_class_info( $trait_fqcn );
+ if ( $trait_info !== null && $trait_info['kind'] === 'interface' ) {
+ $iface_short = $trait->getShortName();
+ $alias = $iface_short . 'Interface';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Interfaces\\{$iface_short} as {$alias}";
+ $interfaces[] = array( 'alias' => $alias );
+ }
+ }
+
+ $code = $this->render_template(
+ 'ObjectTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Output',
+ 'class_name' => $ref->getShortName(),
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'use_statements' => array_unique( $use_stmts ),
+ 'interfaces' => $interfaces,
+ 'fields' => $fields,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Output/' . $ref->getShortName() . '.php';
+ file_put_contents( $path, $code );
+ ++$this->type_count;
+ }
+
+ // ------ Input Type ------
+
+ private function generate_input_type( string $fqcn, \ReflectionClass $ref ): void {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+
+ // Strip "Input" suffix for generated class name, but keep it for GraphQL name.
+ $gen_class_name = $ref->getShortName();
+ if ( str_ends_with( $gen_class_name, 'Input' ) ) {
+ $gen_class_name = substr( $gen_class_name, 0, -5 );
+ }
+
+ $description = $this->get_description( $ref );
+ $use_stmts = array();
+ $fields = array();
+
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+
+ $field = $this->build_field_definition( $prop, 'input', $use_stmts );
+ if ( $field !== null ) {
+ $fields[] = $field;
+ }
+ }
+
+ $code = $this->render_template(
+ 'InputObjectTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Input',
+ 'class_name' => $gen_class_name,
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'use_statements' => array_unique( $use_stmts ),
+ 'fields' => $fields,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Input/' . $gen_class_name . '.php';
+ file_put_contents( $path, $code );
+ ++$this->input_type_count;
+ }
+
+ // ------ Resolver ------
+
+ private function generate_resolver( string $fqcn, \ReflectionClass $ref, string $kind ): void {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ $description = $this->get_description( $ref );
+ $command_alias = $ref->getShortName() . 'Command';
+ $use_stmts = array();
+
+ $execute_method = $ref->getMethod( 'execute' );
+ $params = $execute_method->getParameters();
+
+ // Determine return type.
+ $return_type = $execute_method->getReturnType();
+ $connection_of = $execute_method->getAttributes( ConnectionOf::class );
+ $has_connection_of = ! empty( $connection_of );
+
+ $return_type_expr = $this->get_return_type_expr( $execute_method, $use_stmts );
+
+ // Detect scalar return types (bool, int, float, string) to wrap in a result object.
+ $return_type_name = $return_type instanceof \ReflectionNamedType ? $return_type->getName() : 'mixed';
+ $scalar_return = in_array( $return_type_name, array( 'bool', 'int', 'float', 'string' ), true );
+
+ // Build args and execute params.
+ $args = array();
+ $execute_params = array();
+ $input_converters = array();
+
+ foreach ( $params as $param ) {
+ $param_name = $param->getName();
+
+ // Infrastructure parameters.
+ if ( $param_name === '_query_info' ) {
+ $execute_params[] = array(
+ 'name' => $param_name,
+ 'conversion' => null,
+ 'is_infrastructure' => true,
+ );
+ continue;
+ }
+
+ $param_type = $param->getType();
+ $type_name = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+
+ // Unroll: expand each property of the class into a separate GraphQL arg.
+ if ( $this->should_unroll( $param, $type_name ) ) {
+ $unroll = $this->build_unroll_info( $type_name, $use_stmts );
+ foreach ( $unroll['args'] as $uarg ) {
+ $args[] = $uarg;
+ }
+ $execute_params[] = array(
+ 'name' => $param_name,
+ 'conversion' => null,
+ 'is_infrastructure' => false,
+ 'unroll' => $unroll,
+ );
+ continue;
+ }
+
+ $arg_type_expr = $this->php_type_to_graphql_expr( $type_name, $param_type?->allowsNull() ?? false, $param, $use_stmts );
+
+ $param_description = $this->get_param_description( $param );
+
+ $arg_entry = array(
+ 'name' => $param_name,
+ 'type_expr' => $arg_type_expr,
+ 'description' => $param_description,
+ 'has_default' => $param->isDefaultValueAvailable(),
+ 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
+ );
+ $args[] = $arg_entry;
+
+ // Determine conversion for execute params.
+ $conversion = null;
+ $input_info = $this->get_class_info( $type_name );
+
+ if ( $input_info !== null && $input_info['kind'] === 'input_type' ) {
+ // Input type: needs conversion.
+ $converter_name = 'convert_' . $this->pascal_to_snake_case( ( new \ReflectionClass( $type_name ) )->getShortName() );
+ $conversion = "self::{$converter_name}( \$args['{$param_name}'] )";
+
+ // Build converter if not already done.
+ if ( ! isset( $input_converters[ $type_name ] ) ) {
+ $input_converters[ $type_name ] = $this->build_input_converter( $type_name, $input_converters );
+ }
+ } elseif ( $input_info !== null && $input_info['kind'] === 'enum' ) {
+ // GraphQL engine already resolves enum input values to PHP enum instances,
+ // so no ::from() conversion is needed — just assign the value directly.
+ }
+
+ $execute_params[] = array(
+ 'name' => $param_name,
+ 'conversion' => $conversion,
+ 'is_infrastructure' => false,
+ );
+ }
+
+ // Authorization: check for authorize() method.
+ $authorize_param_names = null;
+ $has_preauthorized = false;
+ if ( $ref->hasMethod( 'authorize' ) ) {
+ $authorize_method = $ref->getMethod( 'authorize' );
+ $authorize_ignored = ! empty( $authorize_method->getAttributes( Ignore::class ) );
+
+ if ( ! $authorize_ignored ) {
+ $validated = $this->validate_authorize_method( $fqcn, $execute_method, $authorize_method );
+ $authorize_param_names = $validated['domain_params'];
+ $has_preauthorized = $validated['has_preauthorized'];
+ }
+ }
+
+ // Resolve the attribute-declared authorization. When authorize() is
+ // present, these values are only used to compute the $_preauthorized
+ // flag passed to the method; check_capabilities() is not generated
+ // in that case (authorize() is the sole guard).
+ $auth = $this->resolve_authorization( $ref );
+ $caps = $auth['caps'];
+ $public_access = $auth['public'];
+
+ // Build the PHP expression that evaluates to the $_preauthorized
+ // boolean at runtime. Only meaningful when authorize() declares the
+ // $_preauthorized parameter; otherwise left at 'false' and unused.
+ $preauthorized_expr = 'false';
+ if ( $has_preauthorized ) {
+ if ( $public_access ) {
+ $preauthorized_expr = 'true';
+ } elseif ( ! empty( $caps ) ) {
+ $preauthorized_expr = implode(
+ ' && ',
+ array_map(
+ fn( $cap ) => sprintf( "current_user_can( '%s' )", addslashes( $cap ) ),
+ $caps
+ )
+ );
+ }
+ }
+
+ $dir_name = $kind === 'query' ? 'GraphQLQueries' : 'GraphQLMutations';
+ $namespace = self::AUTOGENERATED_NAMESPACE . '\\' . $dir_name;
+
+ $code = $this->render_template(
+ 'QueryResolverTemplate.php',
+ array(
+ 'namespace' => $namespace,
+ 'class_name' => $ref->getShortName(),
+ 'graphql_name' => $graphql_name,
+ 'description' => $description,
+ 'command_fqcn' => $fqcn,
+ 'command_alias' => $command_alias,
+ 'return_type_expr' => $return_type_expr,
+ 'use_statements' => array_unique( $use_stmts ),
+ 'args' => $args,
+ 'capabilities' => $caps,
+ 'public_access' => $public_access,
+ 'has_connection_of' => $has_connection_of,
+ 'connection_type_alias' => '',
+ 'execute_params' => $execute_params,
+ 'input_converters' => array_values( $input_converters ),
+ 'authorize_param_names' => $authorize_param_names,
+ 'has_preauthorized' => $has_preauthorized,
+ 'preauthorized_expr' => $preauthorized_expr,
+ 'scalar_return' => $scalar_return,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . "/{$dir_name}/" . $ref->getShortName() . '.php';
+ file_put_contents( $path, $code );
+
+ if ( $kind === 'query' ) {
+ ++$this->query_count;
+ } else {
+ ++$this->mutation_count;
+ }
+ }
+
+ /**
+ * Validate that an authorize() method's parameters are a subset of execute().
+ *
+ * In addition to the domain parameters (which must appear in execute()),
+ * authorize() may declare a single optional infrastructure parameter
+ * `bool $_preauthorized`. When present, the generated resolver passes
+ * `true` if the attribute-declared authorization would have granted access.
+ *
+ * @return array{domain_params: string[], has_preauthorized: bool}
+ */
+ private function validate_authorize_method(
+ string $fqcn,
+ \ReflectionMethod $execute_method,
+ \ReflectionMethod $authorize_method,
+ ): array {
+ $execute_params = array();
+ foreach ( $execute_method->getParameters() as $p ) {
+ $type = $p->getType();
+ $execute_params[ $p->getName() ] = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+ }
+
+ $domain_params = array();
+ $has_preauthorized = false;
+
+ foreach ( $authorize_method->getParameters() as $p ) {
+ $name = $p->getName();
+ $type = $p->getType();
+ $type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+
+ if ( '_preauthorized' === $name ) {
+ if ( 'bool' !== $type_name ) {
+ $this->errors[] = "{$fqcn}: authorize() parameter \$_preauthorized must be typed as bool.";
+ continue;
+ }
+ $has_preauthorized = true;
+ continue;
+ }
+
+ if ( ! array_key_exists( $name, $execute_params ) ) {
+ $this->errors[] = "{$fqcn}: authorize() parameter \${$name} does not exist in execute().";
+ continue;
+ }
+
+ if ( $execute_params[ $name ] !== $type_name ) {
+ $this->errors[] = "{$fqcn}: authorize() parameter \${$name} has type {$type_name}, but execute() has {$execute_params[$name]}.";
+ continue;
+ }
+
+ $domain_params[] = $name;
+ }
+
+ return array(
+ 'domain_params' => $domain_params,
+ 'has_preauthorized' => $has_preauthorized,
+ );
+ }
+
+ /**
+ * Whether a parameter should be unrolled into flat GraphQL args.
+ */
+ private function should_unroll( \ReflectionParameter $param, string $type_name ): bool {
+ // Attribute on the parameter itself.
+ if ( ! empty( $param->getAttributes( Unroll::class ) ) ) {
+ return true;
+ }
+
+ // Attribute on the class.
+ if ( class_exists( $type_name ) ) {
+ $ref = new \ReflectionClass( $type_name );
+ if ( ! empty( $ref->getAttributes( Unroll::class ) ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Whether a Parameter attribute should be unrolled into flat GraphQL args.
+ */
+ private function should_unroll_parameter( Parameter $param ): bool {
+ if ( $param->unroll ) {
+ return true;
+ }
+
+ if ( class_exists( $param->type ) ) {
+ $ref = new \ReflectionClass( $param->type );
+ if ( ! empty( $ref->getAttributes( Unroll::class ) ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Build unroll metadata: the list of GraphQL args and constructor properties
+ * derived from the public properties of the given class.
+ */
+ private function build_unroll_info( string $fqcn, array &$use_stmts ): array {
+ $ref = new \ReflectionClass( $fqcn );
+ $args = array();
+ $properties = array();
+
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+ continue;
+ }
+
+ $prop_name = $prop->getName();
+ $type = $prop->getType();
+ $type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+ $nullable = $type?->allowsNull() ?? false;
+
+ // Description from attribute.
+ $desc_attrs = $prop->getAttributes( Description::class );
+ $description = ! empty( $desc_attrs ) ? $desc_attrs[0]->newInstance()->description : '';
+
+ // Default value.
+ $has_default = $prop->hasDefaultValue();
+ $default = $has_default ? $prop->getDefaultValue() : null;
+
+ // If promoted, the default may come from the constructor parameter.
+ if ( ! $has_default && $prop->isPromoted() ) {
+ $ctor = $ref->getConstructor();
+ if ( $ctor !== null ) {
+ foreach ( $ctor->getParameters() as $ctor_param ) {
+ if ( $ctor_param->getName() === $prop_name && $ctor_param->isDefaultValueAvailable() ) {
+ $has_default = true;
+ $default = $ctor_param->getDefaultValue();
+ break;
+ }
+ }
+ }
+ }
+
+ // GraphQL type expression.
+ $type_expr = $this->php_type_to_graphql_expr( $type_name, $nullable, $prop, $use_stmts );
+
+ $args[] = array(
+ 'name' => $prop_name,
+ 'type_expr' => $type_expr,
+ 'description' => $description,
+ 'has_default' => $has_default,
+ 'default' => $default,
+ );
+
+ // Value expression for the constructor call.
+ $class_info = $this->get_class_info( $type_name );
+ if ( $class_info !== null && $class_info['kind'] === 'enum' ) {
+ // GraphQL engine already resolves enum input values to PHP enum instances,
+ // so no ::from() conversion is needed — just assign the value directly.
+ $value_expr = "\$args['{$prop_name}']";
+ } else {
+ $value_expr = "\$args['{$prop_name}']";
+ if ( $has_default ) {
+ $value_expr .= ' ?? ' . var_export( $default, true );
+ }
+ }
+
+ $properties[] = array(
+ 'name' => $prop_name,
+ 'value_expr' => $value_expr,
+ );
+ }
+
+ return array(
+ 'fqcn' => $fqcn,
+ 'args' => $args,
+ 'properties' => $properties,
+ );
+ }
+
+ private function build_input_converter( string $input_fqcn, array &$input_converters ): array {
+ $ref = new \ReflectionClass( $input_fqcn );
+ $method_name = 'convert_' . $this->pascal_to_snake_case( $ref->getShortName() );
+ $properties = array();
+
+ foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+ $type = $prop->getType();
+ $type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+
+ $conversion = null;
+ $class_info = $this->get_class_info( $type_name );
+
+ if ( $class_info !== null && $class_info['kind'] === 'enum' ) {
+ // GraphQL engine already resolves enum input values to PHP enum instances,
+ // so no ::from() conversion is needed — just assign the value directly.
+ } elseif ( $class_info !== null && $class_info['kind'] === 'input_type' ) {
+ $nested_short = ( new \ReflectionClass( $type_name ) )->getShortName();
+ $nested_method = 'convert_' . $this->pascal_to_snake_case( $nested_short );
+ $prop_name = $prop->getName();
+
+ if ( $type->allowsNull() ) {
+ $conversion = "null !== \$data['{$prop_name}'] ? self::{$nested_method}( \$data['{$prop_name}'] ) : null";
+ } else {
+ $conversion = "self::{$nested_method}( \$data['{$prop_name}'] )";
+ }
+
+ // Recursively build the nested converter if not already registered.
+ if ( ! isset( $input_converters[ $type_name ] ) ) {
+ $input_converters[ $type_name ] = $this->build_input_converter( $type_name, $input_converters );
+ }
+ }
+
+ $properties[] = array(
+ 'name' => $prop->getName(),
+ 'conversion' => $conversion,
+ );
+ }
+
+ return array(
+ 'method_name' => $method_name,
+ 'input_fqcn' => $input_fqcn,
+ 'input_class' => $ref->getShortName(),
+ 'properties' => $properties,
+ );
+ }
+
+ // ------ Connection ------
+
+ private function generate_connection( string $node_type_fqcn ): void {
+ $node_ref = $this->classes[ $node_type_fqcn ]['class'] ?? new \ReflectionClass( $node_type_fqcn );
+ $node_name = $node_ref->getShortName();
+
+ $namespace = self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Pagination';
+ $node_type_class = $node_name;
+ $node_info = $this->get_class_info( $node_type_fqcn );
+ $node_type_namespace = ( null !== $node_info && 'interface' === $node_info['kind'] )
+ ? self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Interfaces'
+ : self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Output';
+ $node_type_alias = $node_name . 'Type';
+ $connection_class_name = $node_name . 'Connection';
+ $edge_class_name = $node_name . 'Edge';
+
+ $connection_code = $this->generate_connection_code( $namespace, $node_type_class, $node_type_namespace, $node_type_alias, $connection_class_name, $edge_class_name );
+ $edge_code = $this->generate_edge_code( $namespace, $node_type_class, $node_type_namespace, $node_type_alias, $edge_class_name );
+
+ $connection_path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination/' . $connection_class_name . '.php';
+ file_put_contents( $connection_path, $connection_code );
+
+ $edge_path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination/' . $edge_class_name . '.php';
+ file_put_contents( $edge_path, $edge_code );
+ }
+
+ private function generate_connection_code( string $namespace, string $node_type_class, string $node_type_namespace, string $node_type_alias, string $connection_class_name, string $edge_class_name ): string {
+ $code = "<?php\n\n";
+ $code .= "declare(strict_types=1);\n\n";
+ $code .= "// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.\n\n";
+ $code .= "namespace {$namespace};\n\n";
+ $code .= "use {$node_type_namespace}\\{$node_type_class} as {$node_type_alias};\n";
+ $code .= "use GraphQL\\Type\\Definition\\ObjectType;\n";
+ $code .= "use GraphQL\\Type\\Definition\\Type;\n\n";
+ $code .= "class {$connection_class_name} {\n";
+ $code .= "\tprivate static ?ObjectType \$instance = null;\n\n";
+ $code .= "\tpublic static function get(): ObjectType {\n";
+ $code .= "\t\tif ( null === self::\$instance ) {\n";
+ $code .= "\t\t\tself::\$instance = new ObjectType(\n";
+ $code .= "\t\t\t\tarray(\n";
+ $code .= "\t\t\t\t\t'name' => '{$connection_class_name}',\n";
+ $code .= "\t\t\t\t\t'description' => __( 'A connection to a list of {$node_type_class} items.', 'woocommerce' ),\n";
+ $code .= "\t\t\t\t\t'fields' => fn() => array(\n";
+ $code .= "\t\t\t\t\t\t'edges' => array(\n";
+ $code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::listOf( Type::nonNull(\n";
+ $code .= "\t\t\t\t\t\t\t\t{$edge_class_name}::get()\n";
+ $code .= "\t\t\t\t\t\t\t) ) ),\n";
+ $code .= "\t\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t\t\t'nodes' => array(\n";
+ $code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::listOf( Type::nonNull(\n";
+ $code .= "\t\t\t\t\t\t\t\t{$node_type_alias}::get()\n";
+ $code .= "\t\t\t\t\t\t\t) ) ),\n";
+ $code .= "\t\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t\t\t'page_info' => array(\n";
+ $code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( PageInfo::get() ),\n";
+ $code .= "\t\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t\t\t'total_count' => array(\n";
+ $code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::int() ),\n";
+ $code .= "\t\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t)\n";
+ $code .= "\t\t\t);\n";
+ $code .= "\t\t}\n";
+ $code .= "\t\treturn self::\$instance;\n";
+ $code .= "\t}\n";
+ $code .= "}\n";
+
+ return $code;
+ }
+
+ private function generate_edge_code( string $namespace, string $node_type_class, string $node_type_namespace, string $node_type_alias, string $edge_class_name ): string {
+ $code = "<?php\n\n";
+ $code .= "declare(strict_types=1);\n\n";
+ $code .= "// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.\n\n";
+ $code .= "namespace {$namespace};\n\n";
+ $code .= "use {$node_type_namespace}\\{$node_type_class} as {$node_type_alias};\n";
+ $code .= "use GraphQL\\Type\\Definition\\ObjectType;\n";
+ $code .= "use GraphQL\\Type\\Definition\\Type;\n\n";
+ $code .= "class {$edge_class_name} {\n";
+ $code .= "\tprivate static ?ObjectType \$instance = null;\n\n";
+ $code .= "\tpublic static function get(): ObjectType {\n";
+ $code .= "\t\tif ( null === self::\$instance ) {\n";
+ $code .= "\t\t\tself::\$instance = new ObjectType(\n";
+ $code .= "\t\t\t\tarray(\n";
+ $code .= "\t\t\t\t\t'name' => '{$edge_class_name}',\n";
+ $code .= "\t\t\t\t\t'fields' => fn() => array(\n";
+ $code .= "\t\t\t\t\t\t'cursor' => array(\n";
+ $code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( Type::string() ),\n";
+ $code .= "\t\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t\t\t'node' => array(\n";
+ $code .= "\t\t\t\t\t\t\t'type' => Type::nonNull( {$node_type_alias}::get() ),\n";
+ $code .= "\t\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t\t),\n";
+ $code .= "\t\t\t\t)\n";
+ $code .= "\t\t\t);\n";
+ $code .= "\t\t}\n";
+ $code .= "\t\treturn self::\$instance;\n";
+ $code .= "\t}\n";
+ $code .= "}\n";
+
+ return $code;
+ }
+
+ private function generate_page_info(): void {
+ $code = $this->render_template(
+ 'PageInfoTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLTypes\\Pagination',
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/GraphQLTypes/Pagination/PageInfo.php';
+ file_put_contents( $path, $code );
+ }
+
+ // ------ Root Types ------
+
+ private function generate_root_query_type( array $queries ): void {
+ $query_data = array();
+ foreach ( $queries as $fqcn => $ref ) {
+ $query_data[] = array(
+ 'class_name' => $ref->getShortName(),
+ 'fqcn' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLQueries\\' . $ref->getShortName(),
+ 'graphql_name' => $this->root_field_name( $fqcn, $ref ),
+ );
+ }
+
+ $code = $this->render_template(
+ 'RootQueryTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE,
+ 'queries' => $query_data,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/RootQueryType.php';
+ file_put_contents( $path, $code );
+ }
+
+ private function generate_root_mutation_type( array $mutations ): void {
+ $mutation_data = array();
+ foreach ( $mutations as $fqcn => $ref ) {
+ $mutation_data[] = array(
+ 'class_name' => $ref->getShortName(),
+ 'fqcn' => self::AUTOGENERATED_NAMESPACE . '\\GraphQLMutations\\' . $ref->getShortName(),
+ 'graphql_name' => $this->root_field_name( $fqcn, $ref ),
+ );
+ }
+
+ $code = $this->render_template(
+ 'RootMutationTypeTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE,
+ 'mutations' => $mutation_data,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/RootMutationType.php';
+ file_put_contents( $path, $code );
+ }
+
+ private function generate_type_registry(): void {
+ $types = array();
+ foreach ( $this->interface_implementors as $implementors ) {
+ foreach ( $implementors as $impl_fqcn ) {
+ $short = ( new \ReflectionClass( $impl_fqcn ) )->getShortName();
+ $types[] = array(
+ 'short_name' => $short,
+ 'fqcn' => self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short}",
+ );
+ }
+ }
+
+ $code = $this->render_template(
+ 'TypeRegistryTemplate.php',
+ array(
+ 'namespace' => self::AUTOGENERATED_NAMESPACE,
+ 'types' => $types,
+ )
+ );
+
+ $path = self::AUTOGENERATED_DIR . '/TypeRegistry.php';
+ file_put_contents( $path, $code );
+ }
+
+ // ========================================================================
+ // Helpers
+ // ========================================================================
+
+ private function write_timestamp(): void {
+ file_put_contents(
+ self::AUTOGENERATED_DIR . '/api_generation_date.txt',
+ gmdate( 'c' )
+ );
+ }
+
+ private function render_template( string $template_name, array $vars ): string {
+ extract( $vars );
+ ob_start();
+ require self::TEMPLATES_DIR . '/' . $template_name;
+ return ob_get_clean();
+ }
+
+ private function get_description( \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase $ref ): string {
+ $attrs = $ref->getAttributes( Description::class );
+ return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+ }
+
+ private function get_param_description( \ReflectionParameter $param ): string {
+ $attrs = $param->getAttributes( Description::class );
+ return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+ }
+
+ private function get_class_info( string $class_name ): ?array {
+ return $this->classes[ $class_name ] ?? null;
+ }
+
+ private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts ): ?array {
+ $type = $prop->getType();
+ $type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+ $nullable = $type?->allowsNull() ?? false;
+ $has_default = $prop->hasDefaultValue();
+
+ // For input types, nullable or has-default fields are optional (no nonNull wrapper).
+ $is_optional = $context === 'input' && ( $nullable || $has_default );
+
+ $type_expr = $this->php_type_to_graphql_expr( $type_name, $nullable || $is_optional, $prop, $use_stmts );
+
+ $description = '';
+ $desc_attrs = $prop->getAttributes( Description::class );
+ if ( ! empty( $desc_attrs ) ) {
+ $description = $desc_attrs[0]->newInstance()->description;
+ }
+
+ $deprecation = $prop->getAttributes( Deprecated::class );
+
+ // Field arguments (only for output types).
+ $args = array();
+ if ( $context === 'output' ) {
+ $param_attrs = $prop->getAttributes( Parameter::class );
+ foreach ( $param_attrs as $pa ) {
+ $param_inst = $pa->newInstance();
+
+ if ( $this->should_unroll_parameter( $param_inst ) ) {
+ $unroll_info = $this->build_unroll_info( $param_inst->type, $use_stmts );
+ foreach ( $unroll_info['args'] as $uarg ) {
+ $args[] = $uarg;
+ }
+ continue;
+ }
+
+ $arg_type_expr = $this->param_type_to_graphql_expr( $param_inst, $use_stmts );
+ $arg_entry = array(
+ 'name' => $param_inst->name,
+ 'type_expr' => $arg_type_expr,
+ 'description' => $param_inst->description,
+ );
+ if ( $param_inst->has_default ) {
+ $arg_entry['default'] = $param_inst->default;
+ }
+ $args[] = $arg_entry;
+ }
+
+ // Merge ParameterDescription.
+ $pd_attrs = $prop->getAttributes( ParameterDescription::class );
+ foreach ( $pd_attrs as $pda ) {
+ $pd_inst = $pda->newInstance();
+ foreach ( $args as &$arg ) {
+ if ( $arg['name'] === $pd_inst->name ) {
+ if ( ! empty( $arg['description'] ) ) {
+ $this->errors[] = "Property \"{$prop->getDeclaringClass()->getShortName()}::\${$prop->getName()}\": parameter \"{$pd_inst->name}\" has a description in both #[Parameter] and #[ParameterDescription].";
+ }
+ $arg['description'] = $pd_inst->description;
+ }
+ }
+ }
+ }
+
+ // Flag connection fields that have pagination args so the template
+ // can generate a resolve callback that slices the connection.
+ $is_connection = $type_name === Connection::class;
+ $has_pagination = $is_connection && ! empty( $args );
+ $paginated_connection = $context === 'output' && $has_pagination;
+
+ return array(
+ 'name' => $prop->getName(),
+ 'type_expr' => $type_expr,
+ 'description' => $description,
+ 'args' => $args,
+ 'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
+ 'paginated_connection' => $paginated_connection,
+ );
+ }
+
+ private function php_type_to_graphql_expr( string $type_name, bool $nullable, \ReflectionProperty|\ReflectionParameter $context, array &$use_stmts ): string {
+ // Check for ScalarType attribute.
+ $scalar_attr = $context->getAttributes( ScalarType::class );
+ if ( ! empty( $scalar_attr ) ) {
+ $scalar_class = $scalar_attr[0]->newInstance()->type;
+ $scalar_short = ( new \ReflectionClass( $scalar_class ) )->getShortName();
+ $alias = $scalar_short . 'Type';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Scalars\\{$scalar_short} as {$alias}";
+ $expr = "{$alias}::get()";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
+ // Check for ArrayOf attribute.
+ $array_of_attr = $context->getAttributes( ArrayOf::class );
+ if ( ! empty( $array_of_attr ) && $type_name === 'array' ) {
+ $item_type = $array_of_attr[0]->newInstance()->type;
+ $item_expr = $this->type_string_to_graphql_expr( $item_type, $use_stmts );
+ $expr = "Type::listOf(Type::nonNull({$item_expr}))";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
+ // Check for ConnectionOf attribute.
+ $conn_attr = $context->getAttributes( ConnectionOf::class );
+ if ( ! empty( $conn_attr ) ) {
+ $node_type = $conn_attr[0]->newInstance()->type;
+ $node_short = ( new \ReflectionClass( $node_type ) )->getShortName();
+ $conn_alias = $node_short . 'ConnectionType';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Pagination\\{$node_short}Connection as {$conn_alias}";
+
+ // Register the connection for generation.
+ $this->connections[ $node_type ] = array(
+ 'node_type' => $node_type,
+ 'source' => $context instanceof \ReflectionProperty ? $context->getDeclaringClass()->getShortName() . '::$' . $context->getName() : 'return type',
+ );
+
+ $expr = "{$conn_alias}::get()";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
+ // Primitive types.
+ $primitive = match ( $type_name ) {
+ 'int' => 'Type::int()',
+ 'float' => 'Type::float()',
+ 'string' => 'Type::string()',
+ 'bool' => 'Type::boolean()',
+ default => null,
+ };
+
+ if ( $primitive !== null ) {
+ return $nullable ? $primitive : "Type::nonNull({$primitive})";
+ }
+
+ // Enum or type reference.
+ $class_info = $this->get_class_info( $type_name );
+ if ( $class_info !== null ) {
+ $short = ( new \ReflectionClass( $type_name ) )->getShortName();
+ if ( $class_info['kind'] === 'enum' ) {
+ $alias = $short . 'Type';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Enums\\{$short} as {$alias}";
+ $expr = "{$alias}::get()";
+ } elseif ( $class_info['kind'] === 'type' ) {
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short}";
+ $expr = "{$short}::get()";
+ } elseif ( $class_info['kind'] === 'input_type' ) {
+ $gen_name = str_ends_with( $short, 'Input' ) ? substr( $short, 0, -5 ) : $short;
+ $alias = $gen_name . 'Input';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Input\\{$gen_name} as {$alias}";
+ $expr = "{$alias}::get()";
+ } else {
+ $expr = 'Type::string()'; // Fallback.
+ }
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
+ // Unknown type — fallback to string.
+ $this->warnings[] = "Unknown type '{$type_name}', falling back to String.";
+ return $nullable ? 'Type::string()' : 'Type::nonNull(Type::string())';
+ }
+
+ private function type_string_to_graphql_expr( string $type, array &$use_stmts ): string {
+ // Primitive string references.
+ return match ( $type ) {
+ 'int' => 'Type::int()',
+ 'float' => 'Type::float()',
+ 'string' => 'Type::string()',
+ 'bool' => 'Type::boolean()',
+ default => $this->class_type_to_graphql_expr( $type, $use_stmts ),
+ };
+ }
+
+ private function class_type_to_graphql_expr( string $fqcn, array &$use_stmts ): string {
+ $class_info = $this->get_class_info( $fqcn );
+ if ( $class_info === null ) {
+ $this->warnings[] = "Unknown class type '{$fqcn}' in ArrayOf.";
+ return 'Type::string()';
+ }
+
+ $short = ( new \ReflectionClass( $fqcn ) )->getShortName();
+
+ return match ( $class_info['kind'] ) {
+ 'enum' => ( function () use ( $short, &$use_stmts ) {
+ $alias = $short . 'Type';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Enums\\{$short} as {$alias}";
+ return "{$alias}::get()";
+ } )(),
+ 'type' => ( function () use ( $short, &$use_stmts ) {
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short}";
+ return "{$short}::get()";
+ } )(),
+ default => 'Type::string()',
+ };
+ }
+
+ private function param_type_to_graphql_expr( Parameter $param, array &$use_stmts ): string {
+ $base = match ( $param->type ) {
+ 'int' => 'Type::int()',
+ 'float' => 'Type::float()',
+ 'string' => 'Type::string()',
+ 'bool' => 'Type::boolean()',
+ default => $this->class_type_to_graphql_expr( $param->type, $use_stmts ),
+ };
+
+ if ( $param->array ) {
+ $base = "Type::listOf(Type::nonNull({$base}))";
+ }
+
+ if ( ! $param->nullable && ! $param->has_default ) {
+ $base = "Type::nonNull({$base})";
+ }
+
+ return $base;
+ }
+
+ private function get_return_type_expr( \ReflectionMethod $method, array &$use_stmts ): string {
+ $return_type = $method->getReturnType();
+ if ( $return_type === null ) {
+ return 'Type::string()';
+ }
+
+ $type_name = $return_type instanceof \ReflectionNamedType ? $return_type->getName() : 'mixed';
+ $nullable = $return_type->allowsNull();
+
+ // Check for ReturnType attribute (interface return).
+ $return_type_attr = $method->getAttributes( ReturnType::class );
+ if ( ! empty( $return_type_attr ) ) {
+ $iface_class = $return_type_attr[0]->newInstance()->type;
+ $iface_info = $this->get_class_info( $iface_class );
+ if ( null !== $iface_info && 'interface' === $iface_info['kind'] ) {
+ $iface_short = ( new \ReflectionClass( $iface_class ) )->getShortName();
+ $alias = $iface_short . 'Interface';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Interfaces\\{$iface_short} as {$alias}";
+ $expr = "{$alias}::get()";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+ }
+
+ // Check for ConnectionOf on the method.
+ $conn_attr = $method->getAttributes( ConnectionOf::class );
+ if ( ! empty( $conn_attr ) ) {
+ $node_type = $conn_attr[0]->newInstance()->type;
+ $node_short = ( new \ReflectionClass( $node_type ) )->getShortName();
+ $conn_alias = $node_short . 'ConnectionType';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Pagination\\{$node_short}Connection as {$conn_alias}";
+
+ $this->connections[ $node_type ] = array(
+ 'node_type' => $node_type,
+ 'source' => $method->getDeclaringClass()->getShortName() . '::execute()',
+ );
+
+ $expr = "{$conn_alias}::get()";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
+ // Output type reference.
+ $class_info = $this->get_class_info( $type_name );
+ if ( $class_info !== null && $class_info['kind'] === 'type' ) {
+ $short = ( new \ReflectionClass( $type_name ) )->getShortName();
+ $alias = $short . 'Type';
+ $use_stmts[] = self::AUTOGENERATED_NAMESPACE . "\\GraphQLTypes\\Output\\{$short} as {$alias}";
+ $expr = "{$alias}::get()";
+ return $nullable ? $expr : "Type::nonNull({$expr})";
+ }
+
+ // Primitive return.
+ $primitive = match ( $type_name ) {
+ 'int' => 'Type::int()',
+ 'float' => 'Type::float()',
+ 'string' => 'Type::string()',
+ 'bool' => 'Type::boolean()',
+ default => 'Type::string()',
+ };
+
+ return $nullable ? $primitive : "Type::nonNull({$primitive})";
+ }
+
+ private function to_screaming_snake_case( string $pascal_case ): string {
+ $result = preg_replace( '/([a-z])([A-Z])/', '$1_$2', $pascal_case );
+ $result = preg_replace( '/([A-Z]+)([A-Z][a-z])/', '$1_$2', $result );
+ return strtoupper( $result );
+ }
+
+ private function pascal_to_snake_case( string $pascal_case ): string {
+ $result = preg_replace( '/([a-z])([A-Z])/', '$1_$2', $pascal_case );
+ $result = preg_replace( '/([A-Z]+)([A-Z][a-z])/', '$1_$2', $result );
+ return strtolower( $result );
+ }
+
+ /**
+ * Compute the GraphQL field name to use for a query or mutation on the
+ * root Query/Mutation type.
+ *
+ * GraphQL convention is PascalCase for type names and camelCase for
+ * field names, so a class like `CreateProduct` becomes the field
+ * `createProduct`. When the user explicitly supplied a `#[Name(...)]`
+ * attribute we respect their string verbatim.
+ */
+ private function root_field_name( string $fqcn, \ReflectionClass $ref ): string {
+ $graphql_name = $this->graphql_names[ $fqcn ];
+ if ( ! empty( $ref->getAttributes( Name::class ) ) ) {
+ return $graphql_name;
+ }
+ return lcfirst( $graphql_name );
+ }
+
+ private function format_with_phpcbf( string $file_path ): void {
+ $wc_dir = realpath( __DIR__ . '/../../../../..' );
+ $phpcbf = $wc_dir . '/vendor/bin/phpcbf';
+ exec( $phpcbf . ' -q ' . escapeshellarg( $file_path ) . ' 2>&1' );
+ }
+
+ private function rmdir_recursive( string $dir ): void {
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( $file->isDir() ) {
+ rmdir( $file->getPathname() );
+ } else {
+ unlink( $file->getPathname() );
+ }
+ }
+
+ rmdir( $dir );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
new file mode 100644
index 00000000000..4a535f0ef05
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api\DesignTime\Scripts;
+
+/**
+ * Checks whether the autogenerated API code is stale and needs rebuilding.
+ */
+class StalenessChecker {
+ /**
+ * Returns true if the autogenerated code is stale (needs rebuilding).
+ */
+ public static function is_stale(): bool {
+ $timestamp_file = __DIR__ . '/../../Autogenerated/api_generation_date.txt';
+
+ if ( ! file_exists( $timestamp_file ) ) {
+ return true;
+ }
+
+ $generation_time = strtotime( file_get_contents( $timestamp_file ) );
+ $api_dir = __DIR__ . '/../../../../Api';
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $api_dir, \FilesystemIterator::SKIP_DOTS )
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( 'php' === $file->getExtension() && $file->getMTime() > $generation_time ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/build-api.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/build-api.php
new file mode 100644
index 00000000000..a3ed5eddd88
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/build-api.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+// Refuse to run outside the CLI: this script wipes and regenerates the
+// Autogenerated/ directory, so a misconfigured web server that accidentally
+// serves this file could destroy the checked-in output on every hit.
+if ( PHP_SAPI !== 'cli' ) {
+ http_response_code( 403 );
+ exit;
+}
+
+if ( PHP_VERSION_ID < 80100 ) {
+ fwrite(
+ STDERR,
+ sprintf(
+ "Error: PHP 8.1 or later is required to run the API build script. Current version: %s.\n",
+ PHP_VERSION
+ )
+ );
+ exit( 2 );
+}
+
+require_once __DIR__ . '/../../../../../vendor/autoload.php';
+
+use Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\ApiBuilder;
+
+$skip_linter = in_array( '--no-linter', $argv, true );
+
+$builder = new ApiBuilder();
+$builder->build( $skip_linter );
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/check-api-staleness.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/check-api-staleness.php
new file mode 100644
index 00000000000..356375b9b84
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/check-api-staleness.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+if ( PHP_VERSION_ID < 80100 ) {
+ fwrite(
+ STDERR,
+ sprintf(
+ "Error: PHP 8.1 or later is required. Current version: %s.\n",
+ PHP_VERSION
+ )
+ );
+ exit( 2 );
+}
+
+require_once __DIR__ . '/../../../../../vendor/autoload.php';
+
+use Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\StalenessChecker;
+
+if ( StalenessChecker::is_stale() ) {
+ fwrite( STDERR, "ERROR: Generated GraphQL API code is out of date.\n" );
+ fwrite( STDERR, "Run 'pnpm run build:api' to regenerate.\n" );
+ exit( 1 );
+}
+
+echo "GraphQL API code is up to date.\n";
+exit( 0 );
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
new file mode 100644
index 00000000000..388f3c0df96
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Template for generating a GraphQL EnumType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var string $enum_fqcn
+ * @var string $enum_alias
+ * @var array $values - each: ['graphql_name', 'case_name', 'description', 'deprecation_reason' => ?string]
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use <?php echo $enum_fqcn; ?> as <?php echo $enum_alias; ?>;
+use GraphQL\Type\Definition\EnumType;
+
+class <?php echo $class_name; ?> {
+ private static ?EnumType $instance = null;
+
+ public static function get(): EnumType {
+ if ( null === self::$instance ) {
+ self::$instance = new EnumType(
+ array(
+ 'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+ 'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+ 'values' => array(
+<?php foreach ( $values as $val ) : ?>
+ '<?php echo $val['graphql_name']; ?>' => array(
+ 'value' => <?php echo $enum_alias; ?>::<?php echo $val['case_name']; ?>,
+ <?php if ( ! empty( $val['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $val['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $val['deprecation_reason'] ) ) : ?>
+ 'deprecationReason' => '<?php echo addslashes( $val['deprecation_reason'] ); ?>',
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
new file mode 100644
index 00000000000..8c446288e60
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * Template for generating a GraphQL InputObjectType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var array $use_statements
+ * @var array $fields - each: ['name', 'type_expr', 'description']
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\InputObjectType;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+ private static ?InputObjectType $instance = null;
+
+ public static function get(): InputObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new InputObjectType(
+ array(
+ 'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+ 'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+ 'fields' => fn() => array(
+<?php foreach ( $fields as $field ) : ?>
+ '<?php echo $field['name']; ?>' => array(
+ 'type' => <?php echo $field['type_expr']; ?>,
+ <?php if ( ! empty( $field['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
new file mode 100644
index 00000000000..5624825876c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Template for generating a GraphQL InterfaceType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @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 $type_map - each: ['fqcn' => string, 'alias' => string] mapping PHP FQCN to generated ObjectType alias
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\InterfaceType;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+ private static ?InterfaceType $instance = null;
+
+ public static function get(): InterfaceType {
+ if ( null === self::$instance ) {
+ self::$instance = new InterfaceType(
+ array(
+ 'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+ 'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+ 'fields' => fn() => array(
+<?php foreach ( $fields as $field ) : ?>
+ '<?php echo $field['name']; ?>' => array(
+ 'type' => <?php echo $field['type_expr']; ?>,
+ <?php if ( ! empty( $field['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['args'] ) ) : ?>
+ 'args' => array(
+ <?php foreach ( $field['args'] as $arg ) : ?>
+ '<?php echo $arg['name']; ?>' => array(
+ 'type' => <?php echo $arg['type_expr']; ?>,
+ <?php if ( array_key_exists( 'default', $arg ) ) : ?>
+ 'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+ <?php if ( ! empty( $arg['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['deprecation_reason'] ) ) : ?>
+ 'deprecationReason' => '<?php echo addslashes( $field['deprecation_reason'] ); ?>',
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+ 'resolveType' => function ( $value ) {
+ $class = get_class( $value );
+ $map = array(
+<?php foreach ( $type_map as $entry ) : ?>
+ '<?php echo $entry['fqcn']; ?>' => <?php echo $entry['alias']; ?>::get(),
+<?php endforeach; ?>
+ );
+ return $map[ $class ] ?? null;
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/MutationResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/MutationResolverTemplate.php
new file mode 100644
index 00000000000..2a6393cebda
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/MutationResolverTemplate.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Template for generating a mutation resolver class.
+ * Identical structure to QueryResolverTemplate — mutations and queries follow the same resolver pattern.
+ *
+ * Variables: same as QueryResolverTemplate.php
+ */
+
+// Re-use the query resolver template since the structure is identical.
+require __DIR__ . '/QueryResolverTemplate.php';
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
new file mode 100644
index 00000000000..96cdc825a42
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Template for generating a GraphQL ObjectType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @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]
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php
+$has_paginated_connection = false;
+foreach ( $fields as $f ) {
+ if ( ! empty( $f['paginated_connection'] ) ) {
+ $has_paginated_connection = true;
+ break;
+ }
+}
+?>
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+<?php if ( $has_paginated_connection ) : ?>
+use Automattic\WooCommerce\Api\Pagination\Connection;
+use Automattic\WooCommerce\Internal\Api\Utils;
+<?php endif; ?>
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+ 'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $interfaces ) ) : ?>
+ 'interfaces' => fn() => array(
+ <?php foreach ( $interfaces as $iface ) : ?>
+ <?php echo $iface['alias']; ?>::get(),
+<?php endforeach; ?>
+ ),
+<?php endif; ?>
+ 'fields' => fn() => array(
+<?php foreach ( $fields as $field ) : ?>
+ '<?php echo $field['name']; ?>' => array(
+ 'type' => <?php echo $field['type_expr']; ?>,
+ <?php if ( ! empty( $field['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['args'] ) ) : ?>
+ 'args' => array(
+ <?php foreach ( $field['args'] as $arg ) : ?>
+ '<?php echo $arg['name']; ?>' => array(
+ 'type' => <?php echo $arg['type_expr']; ?>,
+ <?php if ( array_key_exists( 'default', $arg ) ) : ?>
+ 'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+ <?php if ( ! empty( $arg['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+<?php endif; ?>
+ <?php if ( ! empty( $field['deprecation_reason'] ) ) : ?>
+ 'deprecationReason' => '<?php echo addslashes( $field['deprecation_reason'] ); ?>',
+<?php endif; ?>
+ <?php if ( ! empty( $field['paginated_connection'] ) ) : ?>
+ 'complexity' => Utils::complexity_from_pagination(...),
+ 'resolve' => fn( $parent, array $args ): Connection => Utils::translate_exceptions( fn() => $parent-><?php echo $field['name']; ?>->slice( $args ) ),
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/PageInfoTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/PageInfoTypeTemplate.php
new file mode 100644
index 00000000000..1534a69d7ac
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/PageInfoTypeTemplate.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Template for generating the shared PageInfo GraphQL type class.
+ *
+ * @var string $namespace
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use GraphQL\Type\Definition\ObjectType;
+use GraphQL\Type\Definition\Type;
+
+class PageInfo {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'PageInfo',
+ 'fields' => array(
+ 'has_next_page' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ ),
+ 'has_previous_page' => array(
+ 'type' => Type::nonNull( Type::boolean() ),
+ ),
+ 'start_cursor' => array(
+ 'type' => Type::string(),
+ ),
+ 'end_cursor' => array(
+ 'type' => Type::string(),
+ ),
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
new file mode 100644
index 00000000000..94351b88a8f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Template for generating a query/mutation resolver class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var string $command_fqcn
+ * @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 $capabilities
+ * @var bool $public_access
+ * @var bool $has_connection_of
+ * @var string $connection_type_alias
+ * @var array $execute_params - each: ['name', 'conversion' => ?string, 'is_infrastructure' => bool, 'unroll' => ?array]
+ * @var array $input_converters - each: ['method_name', 'input_fqcn', 'input_class', 'properties' => [['name', 'conversion']]]
+ * @var ?array $authorize_param_names - if non-null, the authorize() method param names (subset of execute params)
+ * @var bool $has_preauthorized - true when authorize() declares a bool $_preauthorized infrastructure param
+ * @var string $preauthorized_expr - PHP expression that evaluates to the $_preauthorized bool at runtime
+ * @var bool $scalar_return - true when execute() returns a scalar (bool, int, float, string)
+ */
+
+$escaped_description = addslashes( $description );
+$has_authorize = $authorize_param_names !== null;
+$has_cap_check = ! $has_authorize && ! $public_access && ! empty( $capabilities );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use <?php echo $command_fqcn; ?> as <?php echo $command_alias; ?>;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+<?php foreach ( $use_statements as $use ) : ?>
+use <?php echo $use; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\ResolveInfo;
+use GraphQL\Type\Definition\Type;
+
+class <?php echo $class_name; ?> {
+ public static function get_field_definition(): array {
+ return array(
+<?php if ( $scalar_return ) : ?>
+ 'type' => Type::nonNull(new \GraphQL\Type\Definition\ObjectType(array(
+ 'name' => '<?php echo $class_name; ?>Result',
+ 'fields' => array(
+ 'result' => array( 'type' => <?php echo $return_type_expr; ?> ),
+ ),
+ ))),
+<?php else : ?>
+ 'type' => <?php echo $return_type_expr; ?>,
+<?php endif; ?>
+<?php if ( $description !== '' ) : ?>
+ 'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+ 'args' => array(
+<?php foreach ( $args as $arg ) : ?>
+ '<?php echo $arg['name']; ?>' => array(
+ 'type' => <?php echo $arg['type_expr']; ?>,
+ <?php if ( ! empty( $arg['description'] ) ) : ?>
+ 'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+ <?php if ( $arg['has_default'] ) : ?>
+ 'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+ ),
+<?php endforeach; ?>
+ ),
+<?php if ( $has_connection_of ) : ?>
+ 'complexity' => Utils::complexity_from_pagination(...),
+<?php endif; ?>
+ 'resolve' => array( self::class, 'resolve' ),
+ );
+ }
+
+ public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+<?php if ( $has_cap_check ) : ?>
+<?php foreach ( $capabilities as $cap ) : ?>
+ Utils::check_current_user_can( '<?php echo addslashes( $cap ); ?>' );
+<?php endforeach; ?>
+
+<?php endif; ?>
+ $command = wc_get_container()->get( <?php echo $command_alias; ?>::class );
+
+ $execute_args = array();
+<?php
+$pagination_fqcn = 'Automattic\\WooCommerce\\Api\\Pagination\\PaginationParams';
+foreach ( $execute_params as $param ) :
+ if ( ! empty( $param['unroll'] ) && $param['unroll']['fqcn'] === $pagination_fqcn ) :
+?>
+ $execute_args['<?php echo $param['name']; ?>'] = Utils::create_pagination_params( $args );
+<?php elseif ( ! empty( $param['unroll'] ) ) : ?>
+ $execute_args['<?php echo $param['name']; ?>'] = Utils::create_input(
+ fn() => new \<?php echo $param['unroll']['fqcn']; ?>(
+<?php foreach ( $param['unroll']['properties'] as $uprop ) : ?>
+ <?php echo $uprop['name']; ?>: <?php echo $uprop['value_expr']; ?>,
+<?php endforeach; ?>
+ )
+ );
+<?php elseif ( $param['is_infrastructure'] && $param['name'] === '_query_info' ) : ?>
+ $execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+<?php elseif ( ! empty( $param['conversion'] ) ) : ?>
+ if ( array_key_exists( '<?php echo $param['name']; ?>', $args ) ) {
+ $execute_args['<?php echo $param['name']; ?>'] = <?php echo $param['conversion']; ?>;
+ }
+<?php else : ?>
+ if ( array_key_exists( '<?php echo $param['name']; ?>', $args ) ) {
+ $execute_args['<?php echo $param['name']; ?>'] = $args['<?php echo $param['name']; ?>'];
+ }
+<?php endif; ?>
+<?php endforeach; ?>
+
+<?php if ( $has_authorize ) : ?>
+ if ( ! Utils::authorize_command( $command, array(
+<?php foreach ( $authorize_param_names as $name ) : ?>
+ '<?php echo $name; ?>' => $execute_args['<?php echo $name; ?>'],
+<?php endforeach; ?>
+<?php if ( $has_preauthorized ) : ?>
+ '_preauthorized' => <?php echo $preauthorized_expr; ?>,
+<?php endif; ?>
+ ) ) ) {
+ throw new \GraphQL\Error\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+
+<?php endif; ?>
+ $result = Utils::execute_command( $command, $execute_args );
+
+<?php if ( $scalar_return ) : ?>
+ return array( 'result' => $result );
+<?php else : ?>
+ return $result;
+<?php endif; ?>
+ }
+<?php foreach ( $input_converters as $converter ) : ?>
+
+ private static function <?php echo $converter['method_name']; ?>( array $data ): \<?php echo $converter['input_fqcn']; ?> {
+ $input = new \<?php echo $converter['input_fqcn']; ?>();
+
+ <?php foreach ( $converter['properties'] as $prop ) : ?>
+ if ( array_key_exists( '<?php echo $prop['name']; ?>', $data ) ) {
+ $input->mark_provided( '<?php echo $prop['name']; ?>' );
+ <?php if ( ! empty( $prop['conversion'] ) ) : ?>
+ $input-><?php echo $prop['name']; ?> = <?php echo $prop['conversion']; ?>;
+<?php else : ?>
+ $input-><?php echo $prop['name']; ?> = $data['<?php echo $prop['name']; ?>'];
+<?php endif; ?>
+ }
+<?php endforeach; ?>
+
+ return $input;
+ }
+<?php endforeach; ?>
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootMutationTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootMutationTypeTemplate.php
new file mode 100644
index 00000000000..241617d85c3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootMutationTypeTemplate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Template for generating the RootMutationType class.
+ *
+ * @var string $namespace
+ * @var array $mutations - each: ['class_name', 'fqcn', 'graphql_name']
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $mutations as $mutation ) : ?>
+use <?php echo $mutation['fqcn']; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\ObjectType;
+
+class RootMutationType {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Mutation',
+ 'fields' => fn() => array(
+<?php foreach ( $mutations as $mutation ) : ?>
+ '<?php echo $mutation['graphql_name']; ?>' => <?php echo $mutation['class_name']; ?>::get_field_definition(),
+<?php endforeach; ?>
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
new file mode 100644
index 00000000000..54ed76c8868
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Template for generating the RootQueryType class.
+ *
+ * @var string $namespace
+ * @var array $queries - each: ['class_name', 'fqcn', 'graphql_name']
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $queries as $query ) : ?>
+use <?php echo $query['fqcn']; ?>;
+<?php endforeach; ?>
+use GraphQL\Type\Definition\ObjectType;
+
+class RootQueryType {
+ private static ?ObjectType $instance = null;
+
+ public static function get(): ObjectType {
+ if ( null === self::$instance ) {
+ self::$instance = new ObjectType(
+ array(
+ 'name' => 'Query',
+ 'fields' => fn() => array(
+<?php foreach ( $queries as $query ) : ?>
+ '<?php echo $query['graphql_name']; ?>' => <?php echo $query['class_name']; ?>::get_field_definition(),
+<?php endforeach; ?>
+ ),
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
new file mode 100644
index 00000000000..6316734c7e3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * Template for generating a GraphQL CustomScalarType class.
+ *
+ * @var string $namespace
+ * @var string $class_name
+ * @var string $graphql_name
+ * @var string $description
+ * @var string $scalar_fqcn
+ * @var string $scalar_alias
+ */
+
+$escaped_description = addslashes( $description );
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+use <?php echo $scalar_fqcn; ?> as <?php echo $scalar_alias; ?>;
+use GraphQL\Type\Definition\CustomScalarType;
+
+class <?php echo $class_name; ?> {
+ private static ?CustomScalarType $instance = null;
+
+ public static function get(): CustomScalarType {
+ if ( null === self::$instance ) {
+ self::$instance = new CustomScalarType(
+ array(
+ 'name' => '<?php echo $graphql_name; ?>',
+<?php if ( $description !== '' ) : ?>
+ 'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+ 'serialize' => fn( $value ) => <?php echo $scalar_alias; ?>::serialize( $value ),
+ 'parseValue' => function ( $value ) {
+ try {
+ return <?php echo $scalar_alias; ?>::parse( $value );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \GraphQL\Error\Error( $e->getMessage() );
+ }
+ },
+ 'parseLiteral' => function ( $value_node, ?array $variables = null ) {
+ if ( $value_node instanceof \GraphQL\Language\AST\StringValueNode ) {
+ try {
+ return <?php echo $scalar_alias; ?>::parse( $value_node->value );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \GraphQL\Error\Error( $e->getMessage() );
+ }
+ }
+ throw new \GraphQL\Error\Error(
+ '<?php echo $graphql_name; ?> must be a string, got: ' . $value_node->kind
+ );
+ },
+ )
+ );
+ }
+ return self::$instance;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/TypeRegistryTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/TypeRegistryTemplate.php
new file mode 100644
index 00000000000..0538ffaeef2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/TypeRegistryTemplate.php
@@ -0,0 +1,40 @@
+<?php
+/**
+ * Template for generating the TypeRegistry class.
+ *
+ * Lists all concrete types that implement interfaces, so the schema
+ * can register them for inline fragment resolution.
+ *
+ * @var string $namespace
+ * @var array $types - each: ['short_name', 'fqcn']
+ */
+?>
+<?php echo '<?php'; ?>
+
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace <?php echo $namespace; ?>;
+
+<?php foreach ( $types as $type ) : ?>
+use <?php echo $type['fqcn']; ?>;
+<?php endforeach; ?>
+
+class TypeRegistry {
+ /**
+ * Return all concrete types that implement interfaces.
+ *
+ * Pass this to the Schema 'types' config so that inline fragments
+ * (e.g. `... on VariableProduct`) are resolvable.
+ *
+ * @return array
+ */
+ public static function get_interface_implementors(): array {
+ return array(
+<?php foreach ( $types as $type ) : ?>
+ <?php echo $type['short_name']; ?>::get(),
+<?php endforeach; ?>
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
new file mode 100644
index 00000000000..456bca157ed
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -0,0 +1,599 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\RootQueryType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\RootMutationType;
+use Automattic\WooCommerce\Internal\Api\Autogenerated\TypeRegistry;
+use GraphQL\GraphQL;
+use GraphQL\Language\AST\DocumentNode;
+use GraphQL\Language\AST\FieldNode;
+use GraphQL\Language\AST\InlineFragmentNode;
+use GraphQL\Language\AST\OperationDefinitionNode;
+use GraphQL\Language\AST\SelectionSetNode;
+use GraphQL\Type\Schema;
+use GraphQL\Error\DebugFlag;
+use GraphQL\Validator\DocumentValidator;
+use GraphQL\Validator\Rules\DisableIntrospection;
+use GraphQL\Validator\Rules\QueryComplexity;
+use GraphQL\Validator\Rules\QueryDepth;
+
+/**
+ * Handles incoming GraphQL requests over the WooCommerce REST API.
+ */
+class GraphQLController {
+ /**
+ * Maximum nesting depth allowed in a GraphQL query.
+ *
+ * Queries exceeding this depth are rejected during validation, before any
+ * resolver runs. See {@see self::get_max_query_depth()} for the accessor.
+ */
+ private const MAX_QUERY_DEPTH = 15;
+
+ /**
+ * Maximum computed complexity score allowed for a GraphQL query.
+ *
+ * Complexity is the sum of per-field scores; connection fields multiply
+ * their child score by the requested page size. Queries exceeding this
+ * score are rejected during validation. See {@see self::get_max_query_complexity()}.
+ */
+ private const MAX_QUERY_COMPLEXITY = 1000;
+
+ /**
+ * Cached GraphQL schema instance.
+ *
+ * @var ?Schema
+ */
+ private ?Schema $schema = null;
+
+ /**
+ * Query cache / APQ resolver.
+ *
+ * @var QueryCache
+ */
+ private QueryCache $query_cache;
+
+ /**
+ * DI: injected by WooCommerce container.
+ *
+ * @internal
+ * @param QueryCache $query_cache The query cache instance.
+ */
+ final public function init( QueryCache $query_cache ): void {
+ $this->query_cache = $query_cache;
+ }
+
+ /**
+ * The maximum nesting depth allowed in a GraphQL query.
+ *
+ * Exposed as a method so the limit can become configurable — e.g. via a
+ * filter or store option — without requiring call-site changes.
+ */
+ public static function get_max_query_depth(): int {
+ return self::MAX_QUERY_DEPTH;
+ }
+
+ /**
+ * The maximum computed complexity score allowed for a GraphQL query.
+ *
+ * Exposed as a method so the limit can become configurable — e.g. via a
+ * filter or store option — without requiring call-site changes.
+ */
+ public static function get_max_query_complexity(): int {
+ return self::MAX_QUERY_COMPLEXITY;
+ }
+
+ /**
+ * Register the GraphQL REST route.
+ */
+ public function register(): void {
+ register_rest_route(
+ 'wc',
+ '/graphql',
+ array(
+ 'methods' => array( 'GET', 'POST' ),
+ 'callback' => array( $this, 'handle_request' ),
+ // Auth is handled per-query/mutation.
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+
+ /**
+ * Handle an incoming GraphQL request.
+ *
+ * @param \WP_REST_Request $request The REST request.
+ */
+ public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
+ try {
+ return $this->process_request( $request );
+ } catch ( \Throwable $e ) {
+ $output = array(
+ 'errors' => array(
+ $this->format_exception( $e, $request ),
+ ),
+ );
+
+ $status = $this->get_error_status( $output['errors'] );
+ return new \WP_REST_Response( $output, $status );
+ }
+ }
+
+ /**
+ * Process the GraphQL request. Extracted so that handle_request() can
+ * wrap everything in a single try/catch that respects debug mode.
+ *
+ * @param \WP_REST_Request $request The REST request.
+ */
+ private function process_request( \WP_REST_Request $request ): \WP_REST_Response {
+ // 2. Parse request. GET query-string `variables` and `extensions`
+ // arrive as JSON strings; decode_json_param() unifies them with the
+ // already-decoded-array path from POST bodies and rejects malformed
+ // or non-object payloads up front so they surface as HTTP 400
+ // INVALID_ARGUMENT instead of as confusing resolver errors (null
+ // decode) or HTTP 500 TypeErrors (scalar decode).
+ $query = $request->get_param( 'query' );
+ $operation_name = $request->get_param( 'operationName' );
+ $variables = $this->decode_json_param( $request->get_param( 'variables' ), 'variables' );
+ $extensions = $this->decode_json_param( $request->get_param( 'extensions' ), 'extensions' );
+
+ // 3. Resolve query (cache lookup / APQ / parse).
+ $source = $this->query_cache->resolve( $query, $extensions );
+ if ( is_array( $source ) ) {
+ return new \WP_REST_Response( $source, $this->get_resolve_error_status( $source ) );
+ }
+
+ // 4. Reject mutations over GET (GraphQL over HTTP spec).
+ if ( 'GET' === $request->get_method() && $this->document_has_mutation( $source, $operation_name ) ) {
+ return new \WP_REST_Response(
+ array(
+ 'errors' => array(
+ array(
+ 'message' => 'Mutations are not allowed over GET requests. Use POST instead.',
+ 'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ),
+ ),
+ ),
+ ),
+ 405
+ );
+ }
+
+ // 5. Load schema.
+ $schema = $this->get_schema();
+
+ // 6. Build validation rules.
+ // A single QueryComplexity instance is kept so its computed score can
+ // be surfaced in the debug extensions after execution.
+ $complexity_rule = new QueryComplexity( self::get_max_query_complexity() );
+ $validation_rules = array_values( DocumentValidator::allRules() );
+ $validation_rules[] = new QueryDepth( self::get_max_query_depth() );
+ $validation_rules[] = $complexity_rule;
+ if ( ! $this->is_introspection_allowed( $request ) ) {
+ $validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
+ }
+
+ // 7. Execute.
+ $result = GraphQL::executeQuery(
+ schema: $schema,
+ source: $source,
+ variableValues: $variables,
+ operationName: $operation_name,
+ validationRules: $validation_rules,
+ );
+
+ // Install an error formatter that guarantees every error carries an
+ // `extensions.code`. Our resolvers route everything through
+ // Utils::execute_command / Utils::authorize_command, which already
+ // translate domain exceptions (ApiException, InvalidArgumentException,
+ // generic Throwable) into coded GraphQL errors at the throw site.
+ // What reaches us uncoded here is webonyx-native validation and
+ // execution output, so we infer from webonyx's ClientAware signal:
+ // client-safe errors become BAD_USER_INPUT (400), the rest become
+ // INTERNAL_ERROR (500).
+ //
+ // In debug mode the same formatter also walks the previous-exception
+ // chain so wrapped errors (e.g. a \ValueError caught by a resolver and
+ // re-thrown as INTERNAL_ERROR) stay visible to the developer instead
+ // of being masked behind the generic "Internal server error" message.
+ $debug_mode = $this->is_debug_mode( $request );
+ $result->setErrorFormatter(
+ function ( \Throwable $error ) use ( $debug_mode ): array {
+ $formatted = \GraphQL\Error\FormattedError::createFromException( $error );
+
+ if ( ! isset( $formatted['extensions']['code'] ) ) {
+ $client_safe = $error instanceof \GraphQL\Error\ClientAware && $error->isClientSafe();
+ $formatted['extensions']['code'] = $client_safe ? 'BAD_USER_INPUT' : 'INTERNAL_ERROR';
+ }
+
+ if ( $debug_mode ) {
+ $chain = $this->extract_previous_chain( $error );
+ if ( ! empty( $chain ) ) {
+ $formatted['extensions']['previous'] = $chain;
+ }
+ }
+
+ return $formatted;
+ }
+ );
+
+ $debug_flags = $this->get_debug_flags( $request );
+ $output = $result->toArray( $debug_flags );
+
+ // 8. Debug-mode metrics: expose the computed complexity and depth so
+ // clients tuning queries can see what the server scored the request at.
+ if ( $this->is_debug_mode( $request ) ) {
+ if ( ! isset( $output['extensions'] ) ) {
+ $output['extensions'] = array();
+ }
+ if ( ! isset( $output['extensions']['debug'] ) ) {
+ $output['extensions']['debug'] = array();
+ }
+ $output['extensions']['debug']['complexity'] = $complexity_rule->getQueryComplexity();
+ $output['extensions']['debug']['depth'] = $this->compute_query_depth( $source, $operation_name );
+ }
+
+ // 9. Determine HTTP status code. GraphQL emits `data: { field: null }`
+ // for nullable root fields even when the resolver errored, so gating
+ // the status override on `data` being absent would leave nearly every
+ // error response on HTTP 200. Always derive the status from the
+ // errors array when one is present — clients that need "200 with
+ // partial data" semantics can still read the `errors` array.
+ $status = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200;
+
+ return new \WP_REST_Response( $output, $status );
+ }
+
+ /**
+ * Build and cache the GraphQL schema.
+ */
+ private function get_schema(): Schema {
+ if ( null === $this->schema ) {
+ $this->schema = new Schema(
+ array(
+ 'query' => RootQueryType::get(),
+ 'mutation' => RootMutationType::get(),
+ 'types' => TypeRegistry::get_interface_implementors(),
+ )
+ );
+ }
+ return $this->schema;
+ }
+
+ /**
+ * Decode an optional JSON-object param (`variables` / `extensions`) into an array.
+ *
+ * WP_REST_Request delivers POST-body params as already-decoded arrays,
+ * but GET query-string equivalents arrive as raw JSON strings. This
+ * helper unifies the two and rejects malformed JSON or non-object
+ * payloads with an InvalidArgumentException — which handle_request()
+ * surfaces as HTTP 400 INVALID_ARGUMENT, rather than letting a null
+ * decode slip through as "no variables" or a scalar decode trigger a
+ * downstream TypeError / HTTP 500.
+ *
+ * @param mixed $value The param value from WP_REST_Request::get_param().
+ * @param string $name The param name, used in error messages.
+ * @return array The decoded object, or an empty array when the param is omitted / empty / JSON null.
+ * @throws \InvalidArgumentException When the payload is not a JSON object or not valid JSON.
+ */
+ private function decode_json_param( $value, string $name ): array {
+ if ( null === $value ) {
+ return array();
+ }
+ if ( is_array( $value ) ) {
+ return $value;
+ }
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ if ( ! is_string( $value ) ) {
+ throw new \InvalidArgumentException(
+ sprintf( 'Argument `%s` must be a JSON object or omitted.', $name )
+ );
+ }
+ if ( '' === $value ) {
+ return array();
+ }
+ $decoded = json_decode( $value, true );
+ if ( JSON_ERROR_NONE !== json_last_error() ) {
+ throw new \InvalidArgumentException(
+ sprintf( 'Argument `%s` is not valid JSON: %s', $name, json_last_error_msg() )
+ );
+ }
+ if ( null === $decoded ) {
+ // Literal "null" JSON payload — treat as omitted.
+ return array();
+ }
+ if ( ! is_array( $decoded ) ) {
+ throw new \InvalidArgumentException(
+ sprintf( 'Argument `%s` must be a JSON object (got %s).', $name, gettype( $decoded ) )
+ );
+ }
+ return $decoded;
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ /**
+ * Determine debug flags based on WP_DEBUG, user role, and query string.
+ *
+ * @param \WP_REST_Request $request The REST request.
+ */
+ private function get_debug_flags( \WP_REST_Request $request ): int {
+ if ( ! $this->is_debug_mode( $request ) ) {
+ return DebugFlag::NONE;
+ }
+ return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
+ }
+
+ /**
+ * Check whether GraphQL introspection is allowed for this request.
+ *
+ * Introspection is permitted if either condition holds:
+ * - The request is in debug mode ({@see self::is_debug_mode()}).
+ * - The caller has the `manage_woocommerce` capability.
+ *
+ * Gating on capability rather than mere authentication keeps the full
+ * schema (including admin-only mutations) hidden from low-privilege
+ * roles such as `customer`, which every storefront account is assigned
+ * at checkout — while still allowing admin tooling (e.g. GraphiQL-like
+ * explorers) to query it.
+ *
+ * @param \WP_REST_Request $request The REST request.
+ */
+ private function is_introspection_allowed( \WP_REST_Request $request ): bool {
+ return $this->is_debug_mode( $request ) || current_user_can( 'manage_woocommerce' );
+ }
+
+ /**
+ * Check if debug mode is active.
+ *
+ * Debug mode is active when either:
+ * - WP_DEBUG is enabled AND the current user is an administrator (or in a local environment).
+ * - The current user is an administrator (or in a local environment) AND `_debug=1` is in the query string.
+ *
+ * @param \WP_REST_Request $request The REST request.
+ */
+ private function is_debug_mode( \WP_REST_Request $request ): bool {
+ if ( ! $this->is_local_environment() && ! current_user_can( 'manage_options' ) ) {
+ return false;
+ }
+
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ return true;
+ }
+
+ return '1' === $request->get_param( '_debug' );
+ }
+
+ /**
+ * Format a caught exception into a GraphQL error array.
+ *
+ * @param \Throwable $e The caught exception.
+ * @param \WP_REST_Request $request The REST request.
+ */
+ private function format_exception( \Throwable $e, \WP_REST_Request $request ): array {
+ if ( $e instanceof ApiException ) {
+ // Caller-supplied extensions come first so the canonical
+ // getErrorCode() can't be silently overridden by an extensions
+ // entry keyed 'code'. Mirrors the same invariant enforced by
+ // Utils::translate_exceptions() for the execute/authorize paths.
+ $error = array(
+ 'message' => $e->getMessage(),
+ 'extensions' => array_merge(
+ $e->getExtensions(),
+ array( 'code' => $e->getErrorCode() )
+ ),
+ );
+ } elseif ( $e instanceof \InvalidArgumentException ) {
+ $error = array(
+ 'message' => $e->getMessage(),
+ 'extensions' => array( 'code' => 'INVALID_ARGUMENT' ),
+ );
+ } else {
+ $error = array(
+ 'message' => 'An unexpected error occurred.',
+ 'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
+ );
+ }
+
+ if ( $this->is_debug_mode( $request ) ) {
+ $error['extensions']['debug'] = array(
+ 'message' => $e->getMessage(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'trace' => $e->getTraceAsString(),
+ );
+
+ $chain = $this->extract_previous_chain( $e );
+ if ( ! empty( $chain ) ) {
+ $error['extensions']['debug']['previous'] = $chain;
+ }
+ }
+
+ return $error;
+ }
+
+ /**
+ * Walk the `getPrevious()` chain of a Throwable and return one entry per
+ * wrapped exception. Used in debug mode so that resolver-level wrappers
+ * (which bury the real cause behind a generic "INTERNAL_ERROR") still
+ * surface the underlying class/message/file/line/trace.
+ *
+ * @param \Throwable $e The outermost exception.
+ * @return array<int, array{class: string, message: string, file: string, line: int, trace: string[]}>
+ */
+ private function extract_previous_chain( \Throwable $e ): array {
+ $chain = array();
+ for ( $prev = $e->getPrevious(); null !== $prev; $prev = $prev->getPrevious() ) {
+ $chain[] = array(
+ 'class' => get_class( $prev ),
+ 'message' => $prev->getMessage(),
+ 'file' => $prev->getFile(),
+ 'line' => $prev->getLine(),
+ 'trace' => explode( "\n", $prev->getTraceAsString() ),
+ );
+ }
+ return $chain;
+ }
+
+ /**
+ * Mapping from machine-readable error codes to HTTP status codes.
+ *
+ * Any code not listed here defaults to 500, so unknown/unrecognised codes
+ * from third-party resolvers stay on the safe side. The error formatter
+ * installed in process_request() guarantees every error carries a code
+ * from this table before get_error_status() inspects it.
+ */
+ private const ERROR_STATUS_MAP = array(
+ 'UNAUTHORIZED' => 401,
+ 'FORBIDDEN' => 403,
+ 'NOT_FOUND' => 404,
+ 'METHOD_NOT_ALLOWED' => 405,
+ 'INVALID_ARGUMENT' => 400,
+ 'BAD_USER_INPUT' => 400,
+ 'GRAPHQL_PARSE_ERROR' => 400,
+ 'GRAPHQL_PARSE_FAILED' => 400,
+ 'GRAPHQL_VALIDATION_FAILED' => 400,
+ 'VALIDATION_ERROR' => 422,
+ 'INTERNAL_ERROR' => 500,
+ );
+
+ /**
+ * Determine the HTTP status code from an array of GraphQL errors.
+ *
+ * Applies the code-to-status lookup to each error and returns the worst
+ * (highest) status seen. A single genuine 5xx among mixed errors surfaces
+ * as 500, which is the more useful signal for monitoring and logs.
+ *
+ * @param array $errors The GraphQL errors array.
+ */
+ private function get_error_status( array $errors ): int {
+ $status = 200;
+ foreach ( $errors as $error ) {
+ $code = $error['extensions']['code'] ?? null;
+ $mapped = self::ERROR_STATUS_MAP[ $code ] ?? 500;
+ if ( $mapped > $status ) {
+ $status = $mapped;
+ }
+ }
+ return $status;
+ }
+
+ /**
+ * Determine the HTTP status code for an error returned by QueryCache::resolve().
+ *
+ * PERSISTED_QUERY_NOT_FOUND uses 200 per the Apollo APQ convention (protocol signal, not error).
+ *
+ * @param array $response The error response array from resolve().
+ */
+ private function get_resolve_error_status( array $response ): int {
+ $code = $response['errors'][0]['extensions']['code'] ?? '';
+
+ if ( 'PERSISTED_QUERY_NOT_FOUND' === $code ) {
+ return 200;
+ }
+
+ return 400;
+ }
+
+ /**
+ * Compute the maximum nesting depth of the executing operation.
+ *
+ * Field selections add one level; inline fragments do not. Named-fragment
+ * spreads are not expanded here — the depth returned is therefore a lower
+ * bound when spreads are present. The webonyx QueryDepth validation rule
+ * (which does expand spreads) remains the authoritative gate; this helper
+ * only produces the metric surfaced in the debug extensions.
+ *
+ * @param DocumentNode $document The parsed GraphQL document.
+ * @param ?string $operation_name The requested operation name, if any.
+ */
+ private function compute_query_depth( DocumentNode $document, ?string $operation_name ): int {
+ $max = 0;
+ foreach ( $document->definitions as $definition ) {
+ if ( ! $definition instanceof OperationDefinitionNode ) {
+ continue;
+ }
+
+ if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
+ continue;
+ }
+
+ $max = max( $max, $this->walk_depth( $definition->selectionSet, 0 ) );
+ }
+
+ return $max;
+ }
+
+ /**
+ * Recursively walk a selection set and return the maximum depth reached.
+ *
+ * @param ?SelectionSetNode $selection_set The selection set to walk, or null for a leaf.
+ * @param int $depth The depth of the selection set's parent.
+ */
+ private function walk_depth( ?SelectionSetNode $selection_set, int $depth ): int {
+ if ( null === $selection_set ) {
+ return $depth;
+ }
+
+ $max = $depth;
+ foreach ( $selection_set->selections as $selection ) {
+ if ( $selection instanceof FieldNode ) {
+ $max = max( $max, $this->walk_depth( $selection->selectionSet, $depth + 1 ) );
+ } elseif ( $selection instanceof InlineFragmentNode ) {
+ $max = max( $max, $this->walk_depth( $selection->selectionSet, $depth ) );
+ }
+ }
+
+ return $max;
+ }
+
+ /**
+ * Check whether the parsed document contains a mutation operation.
+ *
+ * When an operation name is given, only that operation is checked;
+ * otherwise any mutation definition in the document triggers a match.
+ *
+ * @param DocumentNode $document The parsed GraphQL document.
+ * @param ?string $operation_name The requested operation name, if any.
+ */
+ private function document_has_mutation( DocumentNode $document, ?string $operation_name ): bool {
+ foreach ( $document->definitions as $definition ) {
+ if ( ! $definition instanceof OperationDefinitionNode ) {
+ continue;
+ }
+
+ if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
+ continue;
+ }
+
+ if ( 'mutation' === $definition->operation ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if running in a local/development environment.
+ *
+ * Prefers {@see wp_get_environment_type()} when available. Otherwise
+ * parses the site URL and performs a case-insensitive *exact* match
+ * against the hostname — not a substring check, to avoid matching
+ * impostor domains like `mylocalhost.com` or `127.0.0.1.attacker.example`.
+ */
+ private function is_local_environment(): bool {
+ if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
+ return true;
+ }
+
+ $host = wp_parse_url( get_site_url(), PHP_URL_HOST );
+ if ( ! is_string( $host ) ) {
+ return false;
+ }
+
+ $host = strtolower( $host );
+ return 'localhost' === $host || '127.0.0.1' === $host;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Main.php b/plugins/woocommerce/src/Internal/Api/Main.php
new file mode 100644
index 00000000000..16f0a43a3c9
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Main.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Entry point for the WooCommerce GraphQL API.
+ *
+ * This class is intentionally free of PHP 8.0+ syntax so that it can be
+ * loaded and called on PHP 7.4 without parse errors. The PHP-8.1-only
+ * classes (GraphQLController, QueryCache, etc.) are resolved lazily from
+ * the DI container only after is_enabled() confirms PHP 8.1+ is available.
+ */
+class Main {
+ /**
+ * Feature flag slug registered in FeaturesController.
+ */
+ private const FEATURE_SLUG = 'dual_code_graphql_api';
+
+ /**
+ * Cached result of the feature-enabled check, null until first evaluated.
+ *
+ * @var ?bool
+ */
+ private static ?bool $enabled = null;
+
+ /**
+ * Check whether the Dual Code & GraphQL API feature is active.
+ *
+ * Requires PHP 8.1+ and the dual_code_graphql_api feature flag to be
+ * enabled. The result is cached for the lifetime of the request.
+ *
+ * @return bool
+ */
+ public static function is_enabled(): bool {
+ if ( null === self::$enabled ) {
+ self::$enabled = PHP_VERSION_ID >= 80100 && FeaturesUtil::feature_is_enabled( self::FEATURE_SLUG );
+ }
+ return self::$enabled;
+ }
+
+ /**
+ * Register the GraphQL endpoint when the feature is active.
+ *
+ * When the feature is off this is a no-op. Classes in the public
+ * Automattic\WooCommerce\Api\ namespace remain autoloadable — extensions
+ * that want to know whether the feature is active should check
+ * FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' ) rather
+ * than class_exists() on the Api namespace.
+ */
+ public static function register(): void {
+ if ( ! self::is_enabled() ) {
+ return;
+ }
+
+ add_action(
+ 'rest_api_init',
+ static function () {
+ wc_get_container()->get( GraphQLController::class )->register();
+ }
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryCache.php b/plugins/woocommerce/src/Internal/Api/QueryCache.php
new file mode 100644
index 00000000000..9f1c5f2d0e1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/QueryCache.php
@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use GraphQL\Language\AST\DocumentNode;
+use GraphQL\Language\Parser;
+use GraphQL\Utils\AST;
+
+/**
+ * Caches parsed GraphQL ASTs in the WP object cache and implements the
+ * Apollo Automatic Persisted Queries (APQ) protocol.
+ */
+class QueryCache {
+ /**
+ * WP object-cache group.
+ */
+ private const CACHE_GROUP = 'wc-graphql';
+
+ /**
+ * Cache key prefix. Includes the library major version so that upgrading
+ * webonyx/graphql-php naturally invalidates stale entries.
+ *
+ * Update this constant when bumping the major version in composer.json.
+ */
+ private const CACHE_KEY_PREFIX = 'graphql_ast_v15_';
+
+ /**
+ * Time-to-live (in seconds) for a cached parsed query.
+ *
+ * See {@see self::get_cache_ttl()} for the accessor.
+ */
+ private const CACHE_TTL = DAY_IN_SECONDS;
+
+ /**
+ * The time-to-live (in seconds) for a cached parsed query.
+ */
+ public static function get_cache_ttl(): int {
+ return self::CACHE_TTL;
+ }
+
+ /**
+ * Resolve a query string (and optional APQ extensions) into a DocumentNode.
+ *
+ * Returns a DocumentNode on success, or a GraphQL-shaped error array on failure.
+ *
+ * @param ?string $query The GraphQL query string (may be null for APQ hash-only requests).
+ * @param array $extensions The request extensions (may contain persistedQuery).
+ * @return DocumentNode|array
+ */
+ public function resolve( ?string $query, array $extensions ) {
+ $apq = $extensions['persistedQuery'] ?? null;
+
+ if ( is_array( $apq ) && 1 === ( $apq['version'] ?? null ) && ! empty( $apq['sha256Hash'] ) ) {
+ return $this->resolve_apq( $query, $apq['sha256Hash'] );
+ }
+
+ // Standard query — no APQ.
+ if ( empty( $query ) ) {
+ return $this->error_response( 'No query provided.', 'BAD_REQUEST' );
+ }
+
+ $hash = hash( 'sha256', $query );
+ $doc = $this->get_cached_document( $hash );
+ if ( false !== $doc ) {
+ return $doc;
+ }
+
+ return $this->parse_and_cache( $query, $hash );
+ }
+
+ /**
+ * Handle an APQ request (hash present in extensions).
+ *
+ * @param ?string $query The query string, if provided.
+ * @param string $apq_hash The sha256 hash from the persistedQuery extension.
+ * @return DocumentNode|array
+ */
+ private function resolve_apq( ?string $query, string $apq_hash ) {
+ if ( ! empty( $query ) ) {
+ // Registration: query + hash provided.
+ if ( hash( 'sha256', $query ) !== $apq_hash ) {
+ return $this->error_response(
+ 'provided sha does not match query',
+ 'PERSISTED_QUERY_HASH_MISMATCH'
+ );
+ }
+
+ $doc = $this->get_cached_document( $apq_hash );
+ if ( false !== $doc ) {
+ return $doc;
+ }
+
+ return $this->parse_and_cache( $query, $apq_hash );
+ }
+
+ // Hash-only lookup.
+ $doc = $this->get_cached_document( $apq_hash );
+ if ( false !== $doc ) {
+ return $doc;
+ }
+
+ return $this->error_response( 'PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND' );
+ }
+
+ /**
+ * Retrieve a cached DocumentNode by hash.
+ *
+ * @param string $hash The SHA-256 hash.
+ * @return DocumentNode|false
+ */
+ private function get_cached_document( string $hash ) {
+ $cached = wp_cache_get( $this->build_cache_key( $hash ), self::CACHE_GROUP );
+ if ( false === $cached || ! is_array( $cached ) ) {
+ return false;
+ }
+
+ return AST::fromArray( $cached );
+ }
+
+ /**
+ * Parse a query, cache the resulting AST, and return the DocumentNode.
+ *
+ * Returns an error array if the query has a syntax error.
+ *
+ * @param string $query The GraphQL query string.
+ * @param string $hash The SHA-256 hash to cache under.
+ * @return DocumentNode|array
+ */
+ private function parse_and_cache( string $query, string $hash ) {
+ try {
+ $document = Parser::parse( $query, array( 'noLocation' => true ) );
+ } catch ( \GraphQL\Error\SyntaxError $e ) {
+ return $this->error_response( 'GraphQL syntax error: ' . $e->getMessage(), 'GRAPHQL_PARSE_ERROR' );
+ }
+
+ wp_cache_set( $this->build_cache_key( $hash ), $document->toArray(), self::CACHE_GROUP, self::get_cache_ttl() );
+
+ return $document;
+ }
+
+ /**
+ * Build a versioned cache key from a hash.
+ *
+ * @param string $hash The SHA-256 hash.
+ * @return string
+ */
+ private function build_cache_key( string $hash ): string {
+ return self::CACHE_KEY_PREFIX . $hash;
+ }
+
+ /**
+ * Build a GraphQL-shaped error response array.
+ *
+ * @param string $message The error message.
+ * @param string $code The error code for extensions.
+ * @return array
+ */
+ private function error_response( string $message, string $code ): array {
+ return array(
+ 'errors' => array(
+ array(
+ 'message' => $message,
+ 'extensions' => array( 'code' => $code ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryInfoExtractor.php b/plugins/woocommerce/src/Internal/Api/QueryInfoExtractor.php
new file mode 100644
index 00000000000..353787c6b5a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/QueryInfoExtractor.php
@@ -0,0 +1,182 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use GraphQL\Language\AST\ArgumentNode;
+use GraphQL\Language\AST\FieldNode;
+use GraphQL\Language\AST\FragmentDefinitionNode;
+use GraphQL\Language\AST\FragmentSpreadNode;
+use GraphQL\Language\AST\InlineFragmentNode;
+use GraphQL\Language\AST\SelectionSetNode;
+use GraphQL\Type\Definition\ResolveInfo;
+
+/**
+ * Extracts a unified query info tree from a GraphQL ResolveInfo.
+ *
+ * The resulting array captures the full query structure: fields, arguments,
+ * sub-selections, inline fragments, and named fragment spreads.
+ *
+ * Structure rules:
+ * - Leaf field (no args, no sub-selection) => true
+ * - Field with sub-selections => nested associative array
+ * - Field arguments => '__args' reserved key
+ * - Inline fragments => '...TypeName' prefix key
+ * - Named fragment spreads => expanded inline (merged into the parent as
+ * siblings of the other selections), matching how GraphQL evaluates them
+ * - Top-level query args included via '__args'
+ */
+class QueryInfoExtractor {
+ /**
+ * Extract query info from a resolver's ResolveInfo and top-level args.
+ *
+ * @param ResolveInfo $info The GraphQL resolve info.
+ * @param array $args The top-level query arguments.
+ * @return array The unified query info tree.
+ */
+ public static function extract_from_info( ResolveInfo $info, array $args ): array {
+ $result = self::extract( $info->fieldNodes[0]->selectionSet ?? null, $info->variableValues, $info->fragments );
+ if ( ! empty( $args ) ) {
+ $result['__args'] = $args;
+ }
+ return $result;
+ }
+
+ /**
+ * Recursively extract query info from a selection set.
+ *
+ * @param ?SelectionSetNode $selection_set The selection set to process.
+ * @param array $variable_values Variable values for resolving arguments.
+ * @param array<string, FragmentDefinitionNode> $fragments Named fragment definitions from the document.
+ * @return array The query info tree for the selection set.
+ */
+ public static function extract( ?SelectionSetNode $selection_set, array $variable_values, array $fragments = array() ): array {
+ if ( null === $selection_set ) {
+ return array();
+ }
+
+ $result = array();
+
+ foreach ( $selection_set->selections as $selection ) {
+ if ( $selection instanceof FieldNode ) {
+ $field_name = $selection->name->value;
+ $result[ $field_name ] = self::build_field_entry( $selection, $variable_values, $fragments );
+ } elseif ( $selection instanceof InlineFragmentNode ) {
+ $type_name = $selection->typeCondition->name->value;
+ $key = '...' . $type_name;
+ $result[ $key ] = self::extract( $selection->selectionSet, $variable_values, $fragments );
+ } elseif ( $selection instanceof FragmentSpreadNode ) {
+ // Expand named fragment spreads inline: their fields become
+ // siblings of the other selections, matching how GraphQL
+ // evaluates them. Consumers of _query_info (mappers that
+ // check array_key_exists for specific fields) see them the
+ // same as if the fragment had been written inline. Use a
+ // recursive merge so overlapping selections are unioned
+ // rather than replaced — `array_merge` would drop the
+ // existing sub-selection under the same field name.
+ $fragment = $fragments[ $selection->name->value ] ?? null;
+ if ( null === $fragment ) {
+ continue;
+ }
+ $spread = self::extract( $fragment->selectionSet, $variable_values, $fragments );
+ $result = self::merge_selections( $result, $spread );
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Build the entry for a single field node.
+ *
+ * @param FieldNode $field The field node.
+ * @param array $variable_values Variable values for resolving arguments.
+ * @param array<string, FragmentDefinitionNode> $fragments Named fragment definitions from the document.
+ * @return array|bool True for leaf fields, associative array otherwise.
+ */
+ private static function build_field_entry( FieldNode $field, array $variable_values, array $fragments ): array|bool {
+ $has_args = ! empty( $field->arguments ) && count( $field->arguments ) > 0;
+ $has_sub_selection = null !== $field->selectionSet;
+
+ if ( ! $has_args && ! $has_sub_selection ) {
+ return true;
+ }
+
+ $entry = array();
+
+ if ( $has_args ) {
+ $args = array();
+ foreach ( $field->arguments as $arg ) {
+ $args[ $arg->name->value ] = self::resolve_argument_value( $arg, $variable_values );
+ }
+ $entry['__args'] = $args;
+ }
+
+ if ( $has_sub_selection ) {
+ $sub = self::extract( $field->selectionSet, $variable_values, $fragments );
+ $entry = self::merge_selections( $entry, $sub );
+ }
+
+ return $entry;
+ }
+
+ /**
+ * Recursively merge two selection trees produced by extract()/build_field_entry().
+ *
+ * Used wherever selections from different sources are combined under
+ * the same key (notably: named fragment spreads expanded inline). Matches
+ * GraphQL's selection-set merge semantics — overlapping fields have their
+ * sub-selections unioned rather than one replacing the other, which a
+ * shallow `array_merge` would do.
+ *
+ * Rules:
+ * - Key only in one side: kept verbatim.
+ * - Both sides arrays: recurse, unioning children.
+ * - One array, one `true` (leaf): keep the array — it carries the
+ * sub-selection detail, and its presence already implies the field
+ * was requested.
+ * - Both `true`: keep `true`.
+ * - `__args` collisions (same field with different argument values):
+ * the second operand wins. Conflicting field args are a GraphQL
+ * validation error upstream of us, so this path is defensive.
+ *
+ * @param array $a First selection tree.
+ * @param array $b Second selection tree, merged into $a.
+ * @return array The merged tree.
+ */
+ private static function merge_selections( array $a, array $b ): array {
+ foreach ( $b as $key => $value ) {
+ if ( ! array_key_exists( $key, $a ) ) {
+ $a[ $key ] = $value;
+ continue;
+ }
+ $existing = $a[ $key ];
+ if ( is_array( $existing ) && is_array( $value ) ) {
+ $a[ $key ] = self::merge_selections( $existing, $value );
+ } elseif ( is_array( $value ) ) {
+ // One side is `true`, the other is a sub-selection array — keep the array.
+ $a[ $key ] = $value;
+ }
+ // Both true, or existing-array + new-true: keep existing.
+ }
+ return $a;
+ }
+
+ /**
+ * Resolve the value of a single argument node, handling variables.
+ *
+ * @param ArgumentNode $arg The argument node.
+ * @param array $variable_values Variable values.
+ * @return mixed The resolved argument value.
+ */
+ private static function resolve_argument_value( ArgumentNode $arg, array $variable_values ): mixed {
+ $value_node = $arg->value;
+
+ if ( $value_node instanceof \GraphQL\Language\AST\VariableNode ) {
+ return $variable_values[ $value_node->name->value ] ?? null;
+ }
+
+ return \GraphQL\Utils\AST::valueFromASTUntyped( $value_node, $variable_values );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/README.md b/plugins/woocommerce/src/Internal/Api/README.md
new file mode 100644
index 00000000000..a84fa0e588a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/README.md
@@ -0,0 +1,7 @@
+# Important: Internal and experimental code
+
+**ALL** the code that's inside the `Automattic\WooCommerce\Internal` namespace and nested namespaces, or that's annotated with `@internal`, is for exclusive usage of WooCommerce core and must **NEVER** be used in released extensions or otherwise in production environments.
+
+Additionally, the code in this directory (`Automattic\WooCommerce\Internal\Api` namespace and nested namespaces) is part of [an experimental feature](https://github.com/woocommerce/woocommerce/pull/63772) that could get backwards-incompatible changes or even be completely removed in future versions of WooCommerce; moreover, it's infrastructure code that's really not intended for external usage.
+
+If you want to experiment with the feature (**NEVER** in production environments) from the code side, read [the provisional documentation](https://github.com/woocommerce/woocommerce/pull/63772) and look at the classes in the `src/Api` namespace.
diff --git a/plugins/woocommerce/src/Internal/Api/Utils.php b/plugins/woocommerce/src/Internal/Api/Utils.php
new file mode 100644
index 00000000000..682dd0ff95c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Utils.php
@@ -0,0 +1,189 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+/**
+ * Shared utilities for the auto-generated GraphQL resolvers.
+ */
+class Utils {
+ /**
+ * Assert that the current user has the given WordPress capability.
+ *
+ * Throws a GraphQL UNAUTHORIZED error if the check fails. Intended to
+ * be called from generated resolver methods so the capability-check
+ * boilerplate doesn't have to be repeated in every resolver.
+ *
+ * @param string $capability A WordPress capability slug.
+ *
+ * @throws \GraphQL\Error\Error When the current user lacks the capability.
+ */
+ public static function check_current_user_can( string $capability ): void {
+ if ( ! current_user_can( $capability ) ) {
+ throw new \GraphQL\Error\Error(
+ 'You do not have permission to perform this action.',
+ extensions: array( 'code' => 'UNAUTHORIZED' )
+ );
+ }
+ }
+
+ /**
+ * Compute the complexity cost of a paginated connection field.
+ *
+ * Used as the `complexity` callable on every generated resolver field
+ * that returns a `Connection`. Runs during query validation (before
+ * resolver execution, so before `PaginationParams::validate_args()` has
+ * a chance to reject bad input) — so out-of-range / wrong-type values
+ * are clamped to MAX_PAGE_SIZE here. Using MAX_PAGE_SIZE as the
+ * fallback means a malicious attempt to shrink cost via e.g. a
+ * negative `first` value only inflates the computed complexity,
+ * closing the cost-bypass angle.
+ *
+ * @param int $child_complexity The complexity of a single child node.
+ * @param array $args The field arguments (expects `first` / `last`).
+ *
+ * @return int The total complexity for this connection field.
+ */
+ public static function complexity_from_pagination( int $child_complexity, array $args ): int {
+ $requested = $args['first'] ?? $args['last'] ?? \Automattic\WooCommerce\Api\Pagination\PaginationParams::get_default_page_size();
+ $page_size = ( is_int( $requested ) && $requested >= 0 && $requested <= \Automattic\WooCommerce\Api\Pagination\PaginationParams::MAX_PAGE_SIZE )
+ ? $requested
+ : \Automattic\WooCommerce\Api\Pagination\PaginationParams::MAX_PAGE_SIZE;
+ return $page_size * ( $child_complexity + 1 );
+ }
+
+ /**
+ * Build a PaginationParams instance from the standard GraphQL pagination
+ * arguments (first, last, after, before).
+ *
+ * @param array $args The GraphQL field arguments.
+ *
+ * @return \Automattic\WooCommerce\Api\Pagination\PaginationParams
+ * @throws \GraphQL\Error\Error When a pagination value is out of range.
+ */
+ public static function create_pagination_params( array $args ): \Automattic\WooCommerce\Api\Pagination\PaginationParams {
+ return self::create_input(
+ fn() => new \Automattic\WooCommerce\Api\Pagination\PaginationParams(
+ first: $args['first'] ?? null,
+ last: $args['last'] ?? null,
+ after: $args['after'] ?? null,
+ before: $args['before'] ?? null,
+ )
+ );
+ }
+
+ /**
+ * Invoke a factory callable, catching InvalidArgumentException and
+ * converting it to a client-visible GraphQL error.
+ *
+ * Used to wrap construction of unrolled input types (PaginationParams,
+ * ProductFilterInput, etc.) whose constructors may validate their
+ * arguments and throw.
+ *
+ * @param callable $factory A callable that returns the constructed object.
+ *
+ * @return mixed The return value of the factory.
+ * @throws \GraphQL\Error\Error When the factory throws InvalidArgumentException.
+ */
+ public static function create_input( callable $factory ): mixed {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ try {
+ return $factory();
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \GraphQL\Error\Error(
+ $e->getMessage(),
+ extensions: array( 'code' => 'INVALID_ARGUMENT' )
+ );
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+
+ /**
+ * Execute a command's execute() method, translating any thrown exceptions
+ * into spec-compliant GraphQL errors.
+ *
+ * @param object $command The command instance (must have an execute() method).
+ * @param array $execute_args Named arguments to pass to execute().
+ *
+ * @return mixed The return value of execute().
+ * @throws \GraphQL\Error\Error On any exception from the command.
+ */
+ public static function execute_command( object $command, array $execute_args ): mixed {
+ return self::translate_exceptions(
+ static fn() => $command->execute( ...$execute_args )
+ );
+ }
+
+ /**
+ * Invoke a command's authorize() method, translating any thrown exceptions
+ * into spec-compliant GraphQL errors.
+ *
+ * Mirror of execute_command() for the authorize step. Needed because an
+ * authorize() call can throw an ApiException (e.g. AuthorizationException
+ * when a target record does not exist); without this wrapper the
+ * exception would propagate up to webonyx and lose its error code and
+ * user-visible message on its way through the generic error formatter.
+ *
+ * @param object $command The command instance (must have an authorize() method).
+ * @param array $authorize_args Named arguments to pass to authorize().
+ *
+ * @return bool The return value of authorize().
+ * @throws \GraphQL\Error\Error On any exception from the authorize method.
+ */
+ public static function authorize_command( object $command, array $authorize_args ): bool {
+ return self::translate_exceptions(
+ static fn() => $command->authorize( ...$authorize_args )
+ );
+ }
+
+ /**
+ * Invoke a callable, translating any thrown exception into a
+ * spec-compliant GraphQL error with a machine-readable code.
+ *
+ * - ApiException → its own code + extensions, with the original message.
+ * - InvalidArgumentException → INVALID_ARGUMENT, with the original message.
+ * - Any other Throwable → INTERNAL_ERROR, with a generic message; the
+ * original throwable is attached as `previous` for debug-mode surfacing.
+ *
+ * Public so that generated resolvers can wrap Code-API calls that happen
+ * outside the execute()/authorize() pair (e.g. the Connection::slice()
+ * call emitted for nested paginated connection fields, which can throw
+ * InvalidArgumentException when pagination bounds are exceeded).
+ *
+ * @param callable $operation Callable to invoke.
+ *
+ * @return mixed The return value of the callable.
+ * @throws \GraphQL\Error\Error On any exception from the callable.
+ */
+ public static function translate_exceptions( callable $operation ): mixed {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON.
+ try {
+ return $operation();
+ } catch ( \Automattic\WooCommerce\Api\ApiException $e ) {
+ // Caller-supplied extensions come first so the canonical
+ // getErrorCode() can't be silently overridden by an extensions
+ // entry keyed 'code'. The invariant "the code on the wire
+ // equals ApiException::getErrorCode()" is worth enforcing.
+ throw new \GraphQL\Error\Error(
+ $e->getMessage(),
+ extensions: array_merge(
+ $e->getExtensions(),
+ array( 'code' => $e->getErrorCode() )
+ )
+ );
+ } catch ( \InvalidArgumentException $e ) {
+ throw new \GraphQL\Error\Error(
+ $e->getMessage(),
+ extensions: array( 'code' => 'INVALID_ARGUMENT' )
+ );
+ } catch ( \Throwable $e ) {
+ throw new \GraphQL\Error\Error(
+ 'An unexpected error occurred.',
+ previous: $e,
+ extensions: array( 'code' => 'INTERNAL_ERROR' )
+ );
+ }//end try
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 3668b00e0b8..69bcb03f025 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -564,6 +564,18 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
+ 'dual_code_graphql_api' => array(
+ 'name' => __( 'Dual Code & GraphQL API', 'woocommerce' ),
+ 'description' => __(
+ 'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
+ 'woocommerce'
+ ),
+ 'enabled_by_default' => false,
+ 'is_experimental' => true,
+ 'disable_ui' => true,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
PushNotifications::FEATURE_NAME => array(
'name' => __( 'Push Notifications', 'woocommerce' ),
'description' => __(