Commit 118aa6d79a6 for woocommerce
commit 118aa6d79a692b3ebbf3104f688b559ca4bf5913
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Fri May 8 09:19:14 2026 +0200
Flexibilize the dual API response status codes (#64699)
Add an optional `HttpStatusResolver` convention class plugins can ship
at `<api root>\Infrastructure\HttpStatusResolver` with a
`resolve_status(int $default_status, array $output, \WP_REST_Request $request): int`
method, routed through every status-decision point in GraphQLController
via a new `pick_status()` helper. Returning `$default_status` defers to
the framework's existing per-error-code mapping; non-200 returns
override it. Resolver-thrown errors are caught and turned into a clean
500 INTERNAL_ERROR without re-invoking the resolver.
diff --git a/plugins/woocommerce/changelog/flexibilize-graphql-status-codes b/plugins/woocommerce/changelog/flexibilize-graphql-status-codes
new file mode 100644
index 00000000000..09132ca3925
--- /dev/null
+++ b/plugins/woocommerce/changelog/flexibilize-graphql-status-codes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Add extra flexibility to the HTTP status code computation of the dual code+GraphQL API.
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
index e3c2a57f050..0a2ea5661e9 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -170,6 +170,9 @@ class ApiBuilder {
/** @var ?string Optional FQCN of a user-provided `<api_namespace>\Infrastructure\PrincipalResolver` class; null when absent. */
private ?string $principal_resolver_fqcn = null;
+ /** @var ?string Optional FQCN of a user-provided `<api_namespace>\Infrastructure\HttpStatusResolver` class with a public `resolve_status(int, array, \WP_REST_Request): int` method; null when absent. */
+ private ?string $status_resolver_fqcn = null;
+
/**
* Whether the detected PrincipalResolver's `resolve_principal()` declares the
* \WP_REST_Request parameter (true) or omits it (false).
@@ -223,6 +226,7 @@ class ApiBuilder {
$this->detect_class_resolver();
$this->detect_principal_resolver();
+ $this->detect_status_resolver();
$this->discover_authorization_attributes();
$this->discover();
$this->validate();
@@ -246,9 +250,27 @@ class ApiBuilder {
if ( null !== $this->composer_working_dir ) {
echo "Regenerating autoloader...\n";
- exec( 'composer dump-autoload --working-dir=' . escapeshellarg( $this->composer_working_dir ) . ' 2>&1', $output, $code );
- if ( $code !== 0 ) {
- echo 'Warning: composer dump-autoload failed: ' . implode( "\n", $output ) . "\n";
+ // Use chdir() + passthru() instead of exec() with --working-dir.
+ // Some composer post-autoload-dump hooks — notably the Jetpack
+ // autoloader's merged-manifest step that WooCommerce relies on
+ // at runtime — read getcwd() rather than honouring composer's
+ // resolved --working-dir, and silently produce stale manifests
+ // when the two don't match. Setting the actual process CWD
+ // keeps every hook on the same dir; passthru() also streams
+ // output so any failure mode is visible to the developer
+ // rather than being swallowed into a captured array.
+ $original_cwd = getcwd();
+ if ( false === chdir( $this->composer_working_dir ) ) {
+ echo "Warning: could not chdir to {$this->composer_working_dir}; skipping autoloader regeneration.\n";
+ } else {
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_passthru -- design-time CLI script; never runs in a web context.
+ passthru( 'composer dump-autoload', $code );
+ if ( false !== $original_cwd ) {
+ chdir( $original_cwd );
+ }
+ if ( 0 !== $code ) {
+ echo "Warning: composer dump-autoload exited with code {$code}.\n";
+ }
}
}
@@ -415,6 +437,70 @@ class ApiBuilder {
echo " Using principal resolver: {$candidate} (principal type: {$return_type_name}).\n";
}
+ // ========================================================================
+ // HTTP status resolver detection
+ // ========================================================================
+
+ /**
+ * Detect an optional plugin-supplied HTTP status resolver.
+ *
+ * If a class `<api_namespace>\Infrastructure\HttpStatusResolver` exists
+ * and exposes a `public function resolve_status( int, array,
+ * \WP_REST_Request ): int` instance method, the autogenerated
+ * GraphQLController subclass overrides `get_status_resolver()` to return
+ * an instance of it. The base controller then routes every status
+ * decision through that instance, letting plugins override status codes
+ * (e.g. Shopify-style "always 200") without touching the framework.
+ *
+ * Plugins that omit this class get the framework defaults unchanged.
+ * WooCommerce core does not ship a default class.
+ */
+ private function detect_status_resolver(): void {
+ $candidate = $this->api_namespace . '\\Infrastructure\\HttpStatusResolver';
+ if ( ! class_exists( $candidate ) ) {
+ return;
+ }
+ if ( ! method_exists( $candidate, 'resolve_status' ) ) {
+ $this->warnings[] = "HttpStatusResolver class {$candidate} has no resolve_status() method; ignoring.";
+ return;
+ }
+ $method = new \ReflectionMethod( $candidate, 'resolve_status' );
+ if ( ! $method->isPublic() || $method->isStatic() ) {
+ $this->warnings[] = "HttpStatusResolver class {$candidate}::resolve_status() must be a public instance method; ignoring.";
+ return;
+ }
+
+ // Validate `resolve_status(int, array, \WP_REST_Request): int`. A
+ // wrong signature would slip past these checks and surface as a
+ // TypeError deep inside pick_status() at request time, far from its
+ // cause.
+ $params = $method->getParameters();
+ $return_type = $method->getReturnType();
+
+ $is_required_named_type = static function ( ?\ReflectionParameter $param, string $expected ): bool {
+ if ( null === $param || $param->isVariadic() || $param->isOptional() ) {
+ return false;
+ }
+ $type = $param->getType();
+ return $type instanceof \ReflectionNamedType && ltrim( $type->getName(), '\\' ) === $expected;
+ };
+
+ $signature_ok = 3 === count( $params )
+ && $is_required_named_type( $params[0], 'int' )
+ && $is_required_named_type( $params[1], 'array' )
+ && $is_required_named_type( $params[2], 'WP_REST_Request' )
+ && $return_type instanceof \ReflectionNamedType
+ && 'int' === $return_type->getName();
+
+ if ( ! $signature_ok ) {
+ $this->warnings[] = "HttpStatusResolver class {$candidate}::resolve_status() must have signature `resolve_status(int, array, \\WP_REST_Request): int`; ignoring.";
+ return;
+ }
+
+ $this->status_resolver_fqcn = $candidate;
+ echo " Using HTTP status resolver: {$candidate}.\n";
+ }
+
// ========================================================================
// Authorization attribute discovery
// ========================================================================
@@ -2114,6 +2200,7 @@ class ApiBuilder {
'class_resolver_fqcn' => $this->class_resolver_fqcn,
'principal_resolver_fqcn' => $this->principal_resolver_fqcn,
'principal_resolver_takes_request' => $this->principal_resolver_takes_request,
+ 'status_resolver_fqcn' => $this->status_resolver_fqcn,
)
);
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
index 41759271593..b2d50d5b903 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
@@ -7,16 +7,18 @@
* build_schema() to reference the root types that ApiBuilder has just
* generated in the same autogenerated namespace.
*
- * When the build detected a `<api_namespace>\Infrastructure\ClassResolver` or
- * `<api_namespace>\Infrastructure\PrincipalResolver` convention class, this
- * template also emits an override that returns the FQCN, so the base
- * controller routes class instantiation and per-request principal
- * resolution through it.
+ * When the build detected a `<api_namespace>\Infrastructure\ClassResolver`,
+ * `<api_namespace>\Infrastructure\PrincipalResolver`, or
+ * `<api_namespace>\Infrastructure\HttpStatusResolver` convention class, this
+ * template also emits an override that wires it into the base controller —
+ * routing class instantiation, per-request principal resolution, and
+ * HTTP-status decisions respectively.
*
* @var string $namespace
* @var ?string $class_resolver_fqcn - FQCN of the detected ClassResolver, or null.
* @var ?string $principal_resolver_fqcn - FQCN of the detected PrincipalResolver, or null.
* @var bool $principal_resolver_takes_request - true when resolve_principal() declares its \WP_REST_Request parameter; false when zero-arg.
+ * @var ?string $status_resolver_fqcn - FQCN of the detected HttpStatusResolver, or null.
*/
?>
<?php echo '<?php'; ?>
@@ -55,4 +57,10 @@ class GraphQLController extends \Automattic\WooCommerce\Internal\Api\GraphQLCont
return <?php echo $principal_resolver_takes_request ? 'true' : 'false'; ?>;
}
<?php endif; ?>
+<?php if ( null !== $status_resolver_fqcn ) : ?>
+
+ protected function get_status_resolver(): ?object {
+ return new \<?php echo $status_resolver_fqcn; ?>();
+ }
+<?php endif; ?>
}
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index d8bc4f9b161..8782e0d71d1 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -70,6 +70,22 @@ abstract class GraphQLController {
*/
private QueryCache $query_cache;
+ /**
+ * Optional plugin-supplied HTTP status resolver.
+ *
+ * Populated from {@see self::get_status_resolver()} during {@see self::init()}.
+ * Stays null when neither this controller nor its subclass supplies one,
+ * in which case {@see self::pick_status()} short-circuits to the default
+ * status without ever calling a resolver.
+ *
+ * Typed as `?object` rather than a WooCommerce-defined interface so that
+ * sibling plugins do not have to import a WooCommerce type for what is
+ * structurally a single duck-typed method.
+ *
+ * @var ?object
+ */
+ private ?object $status_resolver = null;
+
/**
* DI: injected by WooCommerce container.
*
@@ -78,6 +94,26 @@ abstract class GraphQLController {
*/
final public function init( QueryCache $query_cache ): void {
$this->query_cache = $query_cache;
+ // Resolved through a virtual hook so autogenerated subclasses can
+ // supply a per-plugin resolver without changing init()'s signature.
+ // Late binding picks up the override; init() can stay final.
+ $this->status_resolver = $this->get_status_resolver();
+ }
+
+ /**
+ * Return the HTTP status resolver instance to use for this controller, or
+ * null to opt out. Default: null (use the framework defaults).
+ *
+ * Autogenerated subclasses override this when the plugin ships a
+ * `<plugin-api-namespace>\Infrastructure\HttpStatusResolver` convention
+ * class. The returned object is duck-typed: it must expose
+ * `public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int`,
+ * must return an int, and must not throw. A throw is treated as a plugin
+ * bug and produces a fixed 500 INTERNAL_ERROR response — see
+ * {@see self::pick_status()} and {@see self::handle_request()}.
+ */
+ protected function get_status_resolver(): ?object {
+ return null;
}
/**
@@ -202,6 +238,11 @@ abstract class GraphQLController {
try {
$principal = $this->resolve_request_principal( $request );
return $this->process_request( $request, $principal );
+ } catch ( StatusResolverFailedException $e ) {
+ // Resolver threw on one of the decision points inside
+ // process_request(). Produce a clean 500 without re-invoking
+ // the (broken) resolver.
+ return $this->build_resolver_failure_response();
} catch ( \Throwable $e ) {
$output = array(
'errors' => array(
@@ -209,11 +250,81 @@ abstract class GraphQLController {
),
);
- $status = $this->get_error_status( $output['errors'] );
+ $default = $this->get_error_status( $output['errors'] );
+ try {
+ $status = $this->pick_status( $default, $output, $request );
+ } catch ( StatusResolverFailedException $e2 ) {
+ // Resolver threw specifically when handed the synthetic
+ // errors shape from this catch block. Fall through to the
+ // fixed 500; do not loop back into the resolver.
+ return $this->build_resolver_failure_response();
+ }
+
return new \WP_REST_Response( $output, $status );
}
}
+ /**
+ * Build the canonical 500 response used when the HTTP status resolver
+ * throws. Body shape matches an unhandled internal error so callers
+ * don't need a separate path for "resolver blew up".
+ */
+ private function build_resolver_failure_response(): \WP_REST_Response {
+ return new \WP_REST_Response(
+ array(
+ 'errors' => array(
+ array(
+ 'message' => 'An unexpected error occurred.',
+ 'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
+ ),
+ ),
+ ),
+ 500
+ );
+ }
+
+ /**
+ * Filter the framework-computed default HTTP status through the optional
+ * plugin-supplied status resolver.
+ *
+ * When no resolver is configured this returns the default verbatim. When
+ * a resolver is configured its return value (an int) is returned in
+ * place of the default — which the resolver may also pass through
+ * unchanged for cases it does not want to override.
+ *
+ * Resolver-thrown exceptions are converted into an internal
+ * {@see StatusResolverFailedException} for {@see self::handle_request()}
+ * to handle, so a plugin bug never corrupts or duplicates a response.
+ *
+ * @param int $default The framework-computed default status.
+ * @param array $output The response body about to be sent (may include `errors`/`data`).
+ * @param \WP_REST_Request $request The originating request.
+ *
+ * @throws StatusResolverFailedException When the resolver throws or returns a status code outside the 100..599 HTTP range.
+ */
+ private function pick_status( int $default, array $output, \WP_REST_Request $request ): int {
+ if ( null === $this->status_resolver ) {
+ return $default;
+ }
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal sentinel; never serialised to the wire.
+ try {
+ $resolved = $this->status_resolver->resolve_status( $default, $output, $request );
+ } catch ( \Throwable $e ) {
+ throw new StatusResolverFailedException( 'HTTP status resolver threw.', 0, $e );
+ }
+ // Guard against nonsensical return values. Range-checking outside the
+ // try/catch keeps this exception out of the generic-Throwable wrap
+ // above, so a bad return value surfaces as the same fixed-shape 500
+ // response as a throw — never as a malformed WP_REST_Response.
+ if ( $resolved < 100 || $resolved > 599 ) {
+ throw new StatusResolverFailedException(
+ sprintf( 'HTTP status resolver returned an out-of-range status code: %d.', $resolved )
+ );
+ }
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ return $resolved;
+ }
+
/**
* Process the GraphQL request. Extracted so that handle_request() can
* wrap everything in a single try/catch that respects debug mode.
@@ -236,21 +347,23 @@ abstract class GraphQLController {
// 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 ) );
+ $default = $this->get_resolve_error_status( $source );
+ return new \WP_REST_Response( $source, $this->pick_status( $default, $source, $request ) );
}
// 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' ),
- ),
+ $method_not_allowed_output = array(
+ 'errors' => array(
+ array(
+ 'message' => 'Mutations are not allowed over GET requests. Use POST instead.',
+ 'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ),
),
),
- 405
+ );
+ return new \WP_REST_Response(
+ $method_not_allowed_output,
+ $this->pick_status( 405, $method_not_allowed_output, $request )
);
}
@@ -360,7 +473,8 @@ abstract class GraphQLController {
// 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;
+ $default = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200;
+ $status = $this->pick_status( $default, $output, $request );
return new \WP_REST_Response( $output, $status );
}
diff --git a/plugins/woocommerce/src/Internal/Api/StatusResolverFailedException.php b/plugins/woocommerce/src/Internal/Api/StatusResolverFailedException.php
new file mode 100644
index 00000000000..19c322d122c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/StatusResolverFailedException.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+/**
+ * Internal sentinel raised by {@see GraphQLController::pick_status()} when a
+ * plugin-supplied HTTP status resolver throws.
+ *
+ * The resolver is documented as "must not throw"; this exception lets the
+ * controller distinguish a resolver bug from any other Throwable so it can
+ * short-circuit to a fixed-shape 500 response without re-invoking the
+ * resolver. Never surfaced on the wire.
+ *
+ * @internal
+ */
+final class StatusResolverFailedException extends \RuntimeException {
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/Always200Resolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/Always200Resolver.php
new file mode 100644
index 00000000000..9a8e3e7667a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/Always200Resolver.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that always returns 200 (Shopify-style override).
+ */
+class Always200Resolver {
+ /**
+ * Always return 200 regardless of input.
+ *
+ * @param int $default_status The framework-computed status.
+ * @param array $output The response body about to be sent.
+ * @param \WP_REST_Request $request The originating request.
+ */
+ public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+ unset( $default_status, $output, $request );
+ return 200;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/AlwaysThrowingResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/AlwaysThrowingResolver.php
new file mode 100644
index 00000000000..2e46b045cd5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/AlwaysThrowingResolver.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that throws on every call. Exercises the throw-safety path:
+ * the framework must produce a clean 500 INTERNAL_ERROR without leaking the
+ * exception message and without re-invoking the resolver.
+ */
+class AlwaysThrowingResolver {
+ /**
+ * The exception message used by every throw — kept distinctive so tests
+ * can assert it does NOT appear on the wire.
+ */
+ public const THROW_MESSAGE = 'resolver-implementation-detail';
+
+ /**
+ * Always throw a RuntimeException.
+ *
+ * @param int $default_status The framework-computed status.
+ * @param array $output The response body about to be sent.
+ * @param \WP_REST_Request $request The originating request.
+ *
+ * @throws \RuntimeException Always.
+ */
+ public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+ unset( $default_status, $output, $request );
+ throw new \RuntimeException( self::THROW_MESSAGE );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/EagerCatchOnlyThrowingResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/EagerCatchOnlyThrowingResolver.php
new file mode 100644
index 00000000000..187ea6e24d2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/EagerCatchOnlyThrowingResolver.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that throws ONLY when handed the synthetic errors-shape
+ * built by GraphQLController::handle_request()'s eager-catch block.
+ * Exercises the no-loop guarantee: when the throw originates from inside
+ * that catch, the framework must not re-invoke the resolver while building
+ * the failure response.
+ */
+class EagerCatchOnlyThrowingResolver {
+ /**
+ * Number of calls that returned successfully (i.e. did not match the
+ * eager-catch heuristic).
+ *
+ * @var int
+ */
+ public int $calls_succeeded = 0;
+
+ /**
+ * Number of calls that matched the eager-catch heuristic and threw.
+ *
+ * @var int
+ */
+ public int $calls_thrown = 0;
+
+ /**
+ * Throw only on the synthetic eager-catch shape; pass through otherwise.
+ *
+ * Heuristic: a single error with no `data` key is exactly what
+ * handle_request()'s catch block builds. Other decision points either
+ * include `data` (decision #4) or are not currently exercised by this
+ * fixture.
+ *
+ * @param int $default_status The framework-computed status.
+ * @param array $output The response body about to be sent.
+ * @param \WP_REST_Request $request The originating request.
+ *
+ * @throws \RuntimeException When the input matches the eager-catch shape.
+ */
+ public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+ unset( $request );
+ $has_one_error = isset( $output['errors'] ) && 1 === count( $output['errors'] );
+ $has_no_data = ! array_key_exists( 'data', $output );
+ if ( $has_one_error && $has_no_data ) {
+ ++$this->calls_thrown;
+ throw new \RuntimeException( 'eager-catch-only' );
+ }
+ ++$this->calls_succeeded;
+ return $default_status;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/FixedReturnResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/FixedReturnResolver.php
new file mode 100644
index 00000000000..62a8ddee6cf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/FixedReturnResolver.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that returns a fixed integer regardless of inputs. Used to
+ * exercise pick_status()' range guard with values outside the 100..599 HTTP
+ * range.
+ */
+class FixedReturnResolver {
+ /**
+ * The integer this resolver always returns.
+ *
+ * @var int
+ */
+ private int $value;
+
+ /**
+ * Constructor.
+ *
+ * @param int $value The integer to return from every resolve_status() call.
+ */
+ public function __construct( int $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Always return the configured value.
+ *
+ * @param int $default_status The framework-computed status.
+ * @param array $output The response body about to be sent.
+ * @param \WP_REST_Request $request The originating request.
+ */
+ public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+ unset( $default_status, $output, $request );
+ return $this->value;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/PassThroughResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/PassThroughResolver.php
new file mode 100644
index 00000000000..b15c1ec3a9d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/PassThroughResolver.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that returns the framework default verbatim, recording each
+ * call for later inspection.
+ */
+class PassThroughResolver {
+ /**
+ * One entry per resolve_status() invocation, keyed `default` (the
+ * framework-computed status passed in) and `codes` (the list of GraphQL
+ * error codes seen in the output).
+ *
+ * @var array<int, array{default: int, codes: array<int, ?string>}>
+ */
+ public array $calls = array();
+
+ /**
+ * Pass through the framework default unchanged.
+ *
+ * @param int $default_status The framework-computed status.
+ * @param array $output The response body about to be sent.
+ * @param \WP_REST_Request $request The originating request.
+ */
+ public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+ unset( $request );
+ $this->calls[] = array(
+ 'default' => $default_status,
+ 'codes' => array_map(
+ static fn ( $err ) => $err['extensions']['code'] ?? null,
+ $output['errors'] ?? array()
+ ),
+ );
+ return $default_status;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/RemapInternalErrorResolver.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/RemapInternalErrorResolver.php
new file mode 100644
index 00000000000..a1e873838ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/StatusResolvers/RemapInternalErrorResolver.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers;
+
+/**
+ * Test resolver that remaps INTERNAL_ERROR responses to HTTP 503 and leaves
+ * everything else on the framework default. Exercises partial-override
+ * semantics.
+ */
+class RemapInternalErrorResolver {
+ /**
+ * Remap INTERNAL_ERROR responses to 503; pass through everything else.
+ *
+ * @param int $default_status The framework-computed status.
+ * @param array $output The response body about to be sent.
+ * @param \WP_REST_Request $request The originating request.
+ */
+ public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int {
+ unset( $request );
+ foreach ( $output['errors'] ?? array() as $error ) {
+ if ( 'INTERNAL_ERROR' === ( $error['extensions']['code'] ?? null ) ) {
+ return 503;
+ }
+ }
+ return $default_status;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
new file mode 100644
index 00000000000..8e5729810f1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
@@ -0,0 +1,486 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\GraphQLController;
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\Always200Resolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\AlwaysThrowingResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\EagerCatchOnlyThrowingResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\FixedReturnResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\PassThroughResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\StatusResolvers\RemapInternalErrorResolver;
+use WC_REST_Unit_Test_Case;
+
+/**
+ * End-to-end tests covering the optional plugin-supplied HTTP status
+ * resolver: per-decision-point routing, partial overrides, the Shopify-style
+ * "always 200" override, and throw-safety.
+ *
+ * Test resolvers are wired into the controller via an anonymous subclass of
+ * the dummy autogenerated controller that overrides
+ * {@see GraphQLController::get_status_resolver()}. That mirrors the path
+ * ApiBuilder takes when emitting the autogenerated subclass for a plugin
+ * that ships an `Infrastructure\HttpStatusResolver`.
+ */
+class GraphQLControllerStatusResolverTest extends WC_REST_Unit_Test_Case {
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_cache_flush();
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ DummyStore::reset();
+ DummyContainer::reset();
+ wp_set_current_user( 0 );
+ wp_cache_flush();
+ parent::tearDown();
+ }
+
+ /**
+ * Build a controller wired to the dummy fixture API and the supplied
+ * status resolver.
+ *
+ * @param ?object $resolver Status resolver to inject (or null for none).
+ */
+ private function controller_with_resolver( ?object $resolver ): GraphQLController {
+ $controller = new class( $resolver ) extends DummyGraphQLController {
+ /**
+ * The status resolver injected for this test, or null.
+ *
+ * @var ?object
+ */
+ private ?object $injected_resolver;
+
+ /**
+ * Constructor.
+ *
+ * @param ?object $injected_resolver The resolver to expose via get_status_resolver().
+ */
+ public function __construct( ?object $injected_resolver ) {
+ $this->injected_resolver = $injected_resolver;
+ }
+
+ /**
+ * Return the test-injected resolver instead of the (absent) plugin default.
+ */
+ protected function get_status_resolver(): ?object {
+ return $this->injected_resolver;
+ }
+ };
+ $controller->init( new QueryCache() );
+ return $controller;
+ }
+
+ /**
+ * Build a POST WP_REST_Request with JSON-style body params.
+ *
+ * @param array $body The decoded request body.
+ */
+ private function post_request( array $body ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ foreach ( $body as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+ return $request;
+ }
+
+ /**
+ * Build a GET WP_REST_Request with query-string params.
+ *
+ * @param array $params The query-string parameters.
+ */
+ private function get_request( array $params ): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'GET', '/wc/graphql' );
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+ return $request;
+ }
+
+ // ------------------------------------------------------------------
+ // No-op / pass-through resolver: defaults must be unchanged.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A pass-through resolver leaves the framework defaults unchanged across every decision point.
+ */
+ public function test_pass_through_resolver_preserves_defaults(): void {
+ $resolver = new PassThroughResolver();
+ $sut = $this->controller_with_resolver( $resolver );
+
+ // Decision point #4: success path.
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+ $this->assertSame( 200, $response->get_status() );
+
+ // Decision point #4: UNAUTHORIZED path (widget query without caps).
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) );
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'UNAUTHORIZED', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+
+ // Decision point #2: query cache resolve error (missing query).
+ $response = $sut->handle_request( $this->post_request( array() ) );
+ $this->assertSame( 400, $response->get_status() );
+
+ // Decision point #2: APQ hash miss → 200 PERSISTED_QUERY_NOT_FOUND.
+ $response = $sut->handle_request(
+ $this->post_request(
+ array(
+ 'extensions' => array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => str_repeat( '0', 64 ),
+ ),
+ ),
+ )
+ )
+ );
+ $this->assertSame( 200, $response->get_status() );
+
+ // Decision point #3: mutation over GET → 405.
+ $response = $sut->handle_request(
+ $this->get_request( array( 'query' => 'mutation { increment(value: 1) { result } }' ) )
+ );
+ $this->assertSame( 405, $response->get_status() );
+
+ // Decision point #4 (final return): unhandled RuntimeException is
+ // caught inside Utils::translate_exceptions and surfaces as
+ // INTERNAL_ERROR → 500.
+ $response = $sut->handle_request(
+ $this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ) )
+ );
+ $this->assertSame( 500, $response->get_status() );
+
+ // Decision point #1 (eager-catch): an exception that escapes
+ // process_request entirely. Malformed variables JSON throws
+ // \InvalidArgumentException out of decode_json_param, which lands
+ // in handle_request's catch and formats as INVALID_ARGUMENT → 400.
+ $response = $sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => 'not-json',
+ )
+ )
+ );
+ $this->assertSame( 400, $response->get_status() );
+
+ // Resolver was consulted at every decision point.
+ $this->assertGreaterThanOrEqual( 7, count( $resolver->calls ) );
+ // And every call received the framework's pre-resolver default as the first argument.
+ foreach ( $resolver->calls as $call ) {
+ $this->assertIsInt( $call['default'] );
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Shopify-style: always 200.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A "always 200" resolver overrides every status across every decision point while leaving the body untouched.
+ *
+ * @dataProvider provider_always_200_cases
+ *
+ * @param string $method HTTP method to use.
+ * @param array $payload Request payload.
+ * @param ?string $expected_error_code Expected error code on the wire (null = no errors).
+ * @param int $default_status_without_resolver Status the framework would have returned without a resolver.
+ */
+ public function test_always_200_resolver_overrides_every_decision_point(
+ string $method,
+ array $payload,
+ ?string $expected_error_code,
+ int $default_status_without_resolver
+ ): void {
+ $sut = $this->controller_with_resolver( new Always200Resolver() );
+
+ $request = 'POST' === $method ? $this->post_request( $payload ) : $this->get_request( $payload );
+ $response = $sut->handle_request( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ if ( null === $expected_error_code ) {
+ $this->assertEmpty( $response->get_data()['errors'] ?? array() );
+ } else {
+ $this->assertSame( $expected_error_code, $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ }
+
+ // Sanity-check: without the resolver, the default would be different
+ // (skipping the cases where the default is already 200).
+ if ( 200 !== $default_status_without_resolver ) {
+ $baseline = $this->controller_with_resolver( null );
+ $baseline_response = $baseline->handle_request(
+ 'POST' === $method ? $this->post_request( $payload ) : $this->get_request( $payload )
+ );
+ $this->assertSame( $default_status_without_resolver, $baseline_response->get_status() );
+ }
+ }
+
+ /**
+ * Data provider for {@see self::test_always_200_resolver_overrides_every_decision_point()}.
+ *
+ * @return array<string, array{0: string, 1: array, 2: ?string, 3: int}>
+ */
+ public function provider_always_200_cases(): array {
+ return array(
+ 'success (decision #4, no errors)' => array( 'POST', array( 'query' => '{ greeting { result } }' ), null, 200 ),
+ 'unauthorized (decision #4)' => array( 'POST', array( 'query' => '{ widget(id: 1) { id } }' ), 'UNAUTHORIZED', 401 ),
+ 'parse error (decision #2)' => array( 'POST', array( 'query' => '{ widget(id:' ), 'GRAPHQL_PARSE_ERROR', 400 ),
+ 'missing query (decision #2)' => array( 'POST', array(), 'BAD_REQUEST', 400 ),
+ 'mutation over GET (decision #3)' => array(
+ 'GET',
+ array( 'query' => 'mutation { increment(value: 1) { result } }' ),
+ 'METHOD_NOT_ALLOWED',
+ 405,
+ ),
+ 'unhandled runtime exception (decision #4)' => array(
+ 'POST',
+ array( 'query' => '{ failing(kind: "runtime") { result } }' ),
+ 'INTERNAL_ERROR',
+ 500,
+ ),
+ 'malformed variables JSON (decision #1, eager-catch)' => array(
+ 'GET',
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => 'not-json',
+ ),
+ 'INVALID_ARGUMENT',
+ 400,
+ ),
+ );
+ }
+
+ // ------------------------------------------------------------------
+ // Partial override: remap one code, defer otherwise.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A resolver that remaps INTERNAL_ERROR to 503 only changes that response and leaves everything else on the default.
+ */
+ public function test_partial_override_for_one_code(): void {
+ $sut = $this->controller_with_resolver( new RemapInternalErrorResolver() );
+
+ // Internal error → remapped to 503.
+ $response = $sut->handle_request(
+ $this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ) )
+ );
+ $this->assertSame( 503, $response->get_status() );
+ $this->assertSame( 'INTERNAL_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+
+ // Unauthorized → unchanged at 401.
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) );
+ $this->assertSame( 401, $response->get_status() );
+
+ // Parse error → unchanged at 400.
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ widget(id:' ) ) );
+ $this->assertSame( 400, $response->get_status() );
+
+ // Success → unchanged at 200.
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ // ------------------------------------------------------------------
+ // Throw-safety: any throw produces a clean 500 INTERNAL_ERROR.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A resolver that throws on every call always produces a 500 INTERNAL_ERROR with the canonical body shape.
+ *
+ * @dataProvider provider_throw_safety_inputs
+ *
+ * @param string $method HTTP method to use.
+ * @param array $payload Request payload.
+ */
+ public function test_throwing_resolver_produces_clean_500( string $method, array $payload ): void {
+ $sut = $this->controller_with_resolver( new AlwaysThrowingResolver() );
+
+ $request = 'POST' === $method ? $this->post_request( $payload ) : $this->get_request( $payload );
+ $response = $sut->handle_request( $request );
+
+ $this->assertSame( 500, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+ $this->assertSame( 'An unexpected error occurred.', $data['errors'][0]['message'] ?? null );
+
+ // Resolver's exception message must NOT leak onto the wire.
+ $wire = wp_json_encode( $data );
+ $this->assertIsString( $wire );
+ $this->assertStringNotContainsString( AlwaysThrowingResolver::THROW_MESSAGE, $wire );
+ }
+
+ /**
+ * Data provider for {@see self::test_throwing_resolver_produces_clean_500()}.
+ * One input per status-decision point in the controller.
+ *
+ * @return array<string, array{0: string, 1: array}>
+ */
+ public function provider_throw_safety_inputs(): array {
+ return array(
+ '#1 eager-catch (malformed variables JSON throws out of process_request)' => array(
+ 'GET',
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => 'not-json',
+ ),
+ ),
+ '#2 query-cache resolve error (missing query)' => array( 'POST', array() ),
+ '#3 mutation over GET' => array(
+ 'GET',
+ array( 'query' => 'mutation { increment(value: 1) { result } }' ),
+ ),
+ '#4 success path' => array(
+ 'POST',
+ array( 'query' => '{ greeting { result } }' ),
+ ),
+ '#4 unauthorized path' => array(
+ 'POST',
+ array( 'query' => '{ widget(id: 1) { id } }' ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox A resolver that throws ONLY when handed the eager-catch synthetic errors shape still terminates cleanly with a 500.
+ *
+ * Defensive: this is the configuration where the throw originates from
+ * inside handle_request()'s own catch block. The framework must not loop
+ * back into the resolver while building the failure response.
+ */
+ public function test_resolver_throwing_only_on_eager_catch_does_not_loop(): void {
+ $resolver = new EagerCatchOnlyThrowingResolver();
+ $sut = $this->controller_with_resolver( $resolver );
+
+ // Trigger an exception that escapes process_request (malformed
+ // variables JSON). handle_request()'s catch builds the synthetic
+ // errors shape and calls pick_status — which throws via this
+ // resolver's heuristic.
+ $response = $sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => 'not-json',
+ )
+ )
+ );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'INTERNAL_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+
+ // The resolver was consulted exactly once (no infinite loop, no
+ // post-failure re-entry).
+ $this->assertSame( 1, $resolver->calls_thrown );
+ $this->assertSame( 0, $resolver->calls_succeeded );
+ }
+
+ // ------------------------------------------------------------------
+ // Out-of-range return: same clean 500 INTERNAL_ERROR as a throw.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A resolver that returns an out-of-range int produces a clean 500 INTERNAL_ERROR.
+ *
+ * @dataProvider provider_out_of_range_values
+ *
+ * @param int $value Out-of-range integer to return from the resolver.
+ */
+ public function test_out_of_range_resolver_return_produces_clean_500( int $value ): void {
+ $sut = $this->controller_with_resolver( new FixedReturnResolver( $value ) );
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'INTERNAL_ERROR', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+ $this->assertSame( 'An unexpected error occurred.', $response->get_data()['errors'][0]['message'] ?? null );
+ }
+
+ /**
+ * Data provider for {@see self::test_out_of_range_resolver_return_produces_clean_500()}.
+ * Covers the four "obviously invalid" buckets: zero, negative, sub-100, and above-599.
+ *
+ * @return array<string, array{0: int}>
+ */
+ public function provider_out_of_range_values(): array {
+ return array(
+ 'zero' => array( 0 ),
+ 'negative' => array( -1 ),
+ 'just below 100' => array( 99 ),
+ 'just above 599' => array( 600 ),
+ 'huge positive number' => array( 99999 ),
+ );
+ }
+
+ /**
+ * @testdox A resolver that returns an in-range int (100..599) is honoured even for non-IANA codes.
+ *
+ * The controller's range guard is intentionally permissive — it only
+ * rejects nonsensical values, not arbitrary "unknown" codes. WordPress's
+ * own `status_header()` will silently downgrade non-IANA codes to 200 at
+ * the transport layer, but that is outside the resolver pipeline; this
+ * test asserts the framework hands the resolved value through unchanged.
+ */
+ public function test_in_range_but_non_iana_value_is_honoured_by_pick_status(): void {
+ $sut = $this->controller_with_resolver( new FixedReturnResolver( 222 ) );
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+ // Note: WP_REST_Response::get_status() returns whatever the controller
+ // stored, before WP's status_header() lookup-table filter — so 222
+ // shows up here even though a real HTTP client would observe 200.
+ $this->assertSame( 222, $response->get_status() );
+ }
+
+ // ------------------------------------------------------------------
+ // No resolver: behaviour is unchanged from before this PR.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A controller without a resolver behaves exactly as the framework defaults dictate.
+ */
+ public function test_no_resolver_preserves_defaults(): void {
+ $sut = $this->controller_with_resolver( null );
+
+ $this->assertSame(
+ 200,
+ $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) )->get_status()
+ );
+ $this->assertSame(
+ 401,
+ $sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) )->get_status()
+ );
+ $this->assertSame(
+ 405,
+ $sut->handle_request( $this->get_request( array( 'query' => 'mutation { increment(value: 1) { result } }' ) ) )->get_status()
+ );
+ $this->assertSame(
+ 500,
+ $sut->handle_request( $this->post_request( array( 'query' => '{ failing(kind: "runtime") { result } }' ) ) )->get_status()
+ );
+ $this->assertSame(
+ 400,
+ $sut->handle_request(
+ $this->get_request(
+ array(
+ 'query' => '{ greeting { result } }',
+ 'variables' => 'not-json',
+ )
+ )
+ )->get_status()
+ );
+ }
+}