GraphQLControllerBase.php
<?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Api\Infrastructure;
use Automattic\WooCommerce\Api\ApiException;
use Automattic\WooCommerce\Api\Infrastructure\Schema\Schema;
use Automattic\WooCommerce\Api\Utils\SchemaHandle;
use Automattic\WooCommerce\Internal\Api\QueryCache;
use Automattic\WooCommerce\Internal\Api\QueryComplexityRule;
use Automattic\WooCommerce\Internal\Api\QueryDepthRule;
use Automattic\WooCommerce\Internal\Api\StatusResolverFailedException;
use Automattic\WooCommerce\Vendor\GraphQL\Error\DebugFlag;
use Automattic\WooCommerce\Vendor\GraphQL\GraphQL;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\InlineFragmentNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\OperationDefinitionNode;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\DisableIntrospection;
/**
* Handles incoming GraphQL requests over the WooCommerce REST API.
*
* Abstract: the autogenerated `GraphQLController` subclass emitted by
* ApiBuilder (both for WooCommerce core and for sibling plugins reusing this
* infrastructure) is the concrete class. The public surface plugins extend
* is engine-decoupled — the abstract {@see self::build_schema()} returns
* {@see Schema}, a stable subclass of the underlying engine's schema type,
* so a future engine swap doesn't break already-committed autogen trees.
*/
abstract class GraphQLControllerBase {
/**
* Default nesting-depth limit applied when the option is unset or non-positive.
*
* Queries exceeding the configured limit are rejected during validation,
* before any resolver runs. See {@see self::get_max_query_depth()} for the accessor.
*/
public const DEFAULT_MAX_QUERY_DEPTH = 15;
/**
* Default complexity-score limit applied when the option is unset or non-positive.
*
* Complexity is the sum of per-field scores; connection fields multiply
* their child score by the requested page size. Queries exceeding the
* configured limit are rejected during validation. See
* {@see self::get_max_query_complexity()} for the accessor.
*/
public const DEFAULT_MAX_QUERY_COMPLEXITY = 1000;
/**
* Default path (relative to /wp-json/) at which the GraphQL route is registered.
*
* Used as the fallback when the {@see Main::OPTION_ENDPOINT_URL} option is
* unset or was stored in an invalid form. See {@see self::get_endpoint_url()}
* for the accessor.
*/
public const DEFAULT_ENDPOINT_URL = 'wc/graphql';
/**
* Regex matching one valid path segment of the endpoint URL.
*
* Constrained to the character class WordPress REST routes accept
* (alphanumerics, underscores, hyphens). Shared with {@see \Automattic\WooCommerce\Internal\Api\Settings::sanitize_endpoint_url()}
* so the UI sanitizer and the controller-side fallback stay in lockstep.
*/
public const ENDPOINT_URL_SEGMENT_PATTERN = '/^[A-Za-z0-9_\-]+$/';
/**
* Cached GraphQL schema instance.
*
* @var ?Schema
*/
private ?Schema $schema = null;
/**
* Cached public-facing schema handle wrapping {@see self::$schema}.
*
* @var ?SchemaHandle
*/
private ?SchemaHandle $schema_handle = null;
/**
* Query cache / APQ resolver.
*
* @var QueryCache
*/
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.
*
* @internal
* @param QueryCache $query_cache The query cache instance.
*/
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;
}
/**
* The maximum nesting depth allowed in a GraphQL query.
*
* Reads the {@see Main::OPTION_MAX_QUERY_DEPTH} store option; falls back
* to {@see self::DEFAULT_MAX_QUERY_DEPTH} when the option is unset, empty,
* or non-positive.
*/
public static function get_max_query_depth(): int {
$value = (int) get_option( Main::OPTION_MAX_QUERY_DEPTH, self::DEFAULT_MAX_QUERY_DEPTH );
return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_DEPTH;
}
/**
* The maximum computed complexity score allowed for a GraphQL query.
*
* Reads the {@see Main::OPTION_MAX_QUERY_COMPLEXITY} store option; falls
* back to {@see self::DEFAULT_MAX_QUERY_COMPLEXITY} when the option is
* unset, empty, or non-positive.
*/
public static function get_max_query_complexity(): int {
$value = (int) get_option( Main::OPTION_MAX_QUERY_COMPLEXITY, self::DEFAULT_MAX_QUERY_COMPLEXITY );
return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_COMPLEXITY;
}
/**
* The path (relative to /wp-json/) at which the GraphQL route is registered.
*
* Reads the {@see Main::OPTION_ENDPOINT_URL} store option; falls back to
* {@see self::DEFAULT_ENDPOINT_URL} when the option is unset, empty, or
* fails {@see self::is_valid_endpoint_url()}. The UI already validates on
* save, so this defense-in-depth guard only fires for CLI-set option values.
*/
public static function get_endpoint_url(): string {
$value = trim( (string) get_option( Main::OPTION_ENDPOINT_URL, self::DEFAULT_ENDPOINT_URL ), '/' );
if ( ! self::is_valid_endpoint_url( $value ) ) {
return self::DEFAULT_ENDPOINT_URL;
}
return $value;
}
/**
* Whether a value is a valid endpoint URL.
*
* Requires at least two non-empty path segments (so register_rest_route()
* has both a namespace and a route), each matching
* {@see self::ENDPOINT_URL_SEGMENT_PATTERN}. Mirrors the rules enforced on
* save by {@see \Automattic\WooCommerce\Internal\Api\Settings::sanitize_endpoint_url()}, so values that bypass
* the UI (e.g. CLI-set options) get the same treatment.
*
* @param string $value Endpoint URL with surrounding slashes already stripped.
*/
private static function is_valid_endpoint_url( string $value ): bool {
if ( '' === $value ) {
return false;
}
$parts = explode( '/', $value );
if ( count( $parts ) < 2 ) {
return false;
}
foreach ( $parts as $part ) {
if ( '' === $part || ! preg_match( self::ENDPOINT_URL_SEGMENT_PATTERN, $part ) ) {
return false;
}
}
return true;
}
/**
* Split the endpoint URL into the `[namespace, route]` pair that
* register_rest_route() expects.
*
* The last path segment becomes the route; everything before it becomes
* the namespace. E.g. `wc/v4/graphql` → `['wc/v4', '/graphql']`.
*
* @return array{0: string, 1: string}
*/
private static function split_endpoint_url(): array {
$parts = explode( '/', self::get_endpoint_url() );
$route = '/' . array_pop( $parts );
$namespace = implode( '/', $parts );
return array( $namespace, $route );
}
/**
* Register the GraphQL REST route.
*/
public function register(): void {
$methods = Main::filter_methods_against_settings( array( 'GET', 'POST' ) );
if ( empty( $methods ) ) {
return;
}
list( $namespace, $route ) = self::split_endpoint_url();
register_rest_route(
$namespace,
$route,
array(
'methods' => $methods,
'callback' => array( $this, 'handle_request' ),
// Auth is handled per-query/mutation.
'permission_callback' => '__return_true',
)
);
}
/**
* Handle an incoming GraphQL request.
*
* Resolves the principal first so debug-mode / introspection checks can
* consult it from inside both `process_request()` and the top-level
* exception formatter. When `resolve_request_principal()` itself throws
* (e.g. an InvalidTokenException from a plugin's PrincipalResolver),
* `$principal` stays null and the resulting error response carries no
* debug info — by design, since the caller failed to authenticate.
*
* @param \WP_REST_Request $request The REST request.
*/
public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
$principal = null;
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( $e, $request, $principal );
} catch ( \Throwable $e ) {
$output = array(
'errors' => array(
$this->format_exception( $e, $request, $principal ),
),
);
$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( $e2, $request, $principal );
}
return new \WP_REST_Response( $output, $status );
}
}
/**
* Build the canonical 500 response used when the HTTP status resolver
* throws or returns an out-of-range value. Body shape matches an
* unhandled internal error so callers don't need a separate path for
* "resolver blew up".
*
* In debug mode, attaches `extensions.debug` (message, file, line, trace)
* for the wrapper exception, plus an `extensions.previous` chain when the
* resolver itself threw — mirroring the shape that {@see self::format_exception()}
* produces for the generic-Throwable path. Outside debug mode the body
* stays purely generic so resolver internals never leak to anonymous callers.
*
* @param StatusResolverFailedException $e The wrapper exception thrown by {@see self::pick_status()}.
* @param \WP_REST_Request $request The originating REST request.
* @param ?object $principal The resolved principal, or null when resolution failed.
*/
private function build_resolver_failure_response(
StatusResolverFailedException $e,
\WP_REST_Request $request,
?object $principal
): \WP_REST_Response {
$error = array(
'message' => 'An unexpected error occurred.',
'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
);
if ( $this->is_debug_mode( $principal, $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']['previous'] = $chain;
}
}
return new \WP_REST_Response( array( 'errors' => array( $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.
*
* @param \WP_REST_Request $request The REST request.
* @param object $principal The principal resolved by handle_request(); never null when this is reached.
*/
private function process_request( \WP_REST_Request $request, object $principal ): \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 ) ) {
$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 ) ) {
$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' ),
),
),
);
return new \WP_REST_Response(
$method_not_allowed_output,
$this->pick_status( 405, $method_not_allowed_output, $request )
);
}
// 5. Load schema.
$schema = $this->get_engine_schema();
// 6. Build validation rules.
// A single complexity-rule instance is kept so its computed score can
// be surfaced in the debug extensions after execution.
$complexity_rule = new QueryComplexityRule( self::get_max_query_complexity() );
$validation_rules = array_values( DocumentValidator::allRules() );
$validation_rules[] = new QueryDepthRule( self::get_max_query_depth() );
$validation_rules[] = $complexity_rule;
if ( ! $this->is_introspection_allowed( $principal, $request ) ) {
$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
}
// 7. Execute. The context value is an ArrayObject (not a plain array)
// so root resolvers can mutate it — specifically to thread the root
// query's metadata into `$context['_query_metadata']` for downstream
// field-level authorization gates. ArrayObject preserves the
// `$context['key']` read syntax via ArrayAccess. The context carries
// the resolved principal through to autogenerated resolvers, which
// expose it as the `_principal` infrastructure parameter when commands
// declare it on their authorize()/execute() methods. Request-derived
// data that resolvers need is carried by the principal class itself —
// populated by the PrincipalResolver, the only component wired to the
// HTTP transport.
$result = GraphQL::executeQuery(
schema: $schema,
source: $source,
contextValue: new \ArrayObject(
array(
'principal' => $principal,
)
),
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( $principal, $request );
$result->setErrorFormatter(
function ( \Throwable $error ) use ( $debug_mode ): array {
$formatted = \Automattic\WooCommerce\Vendor\GraphQL\Error\FormattedError::createFromException( $error );
if ( ! isset( $formatted['extensions']['code'] ) ) {
$client_safe = $error instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\ClientAware && $error->isClientSafe();
$formatted['extensions']['code'] = $client_safe ? 'BAD_USER_INPUT' : 'INTERNAL_ERROR';
}
// SerializationError (thrown during schema-type coercion, e.g. when
// a resolver returns an Int that doesn't fit 32 bits) extends
// \Exception rather than webonyx's ClientAware Error, so it lands
// in the INTERNAL_ERROR bucket above. Its message is actually
// client-actionable ("value out of range — send smaller inputs"),
// so promote it to BAD_USER_INPUT when it shows up anywhere in
// the previous-exception chain.
if ( 'BAD_USER_INPUT' !== ( $formatted['extensions']['code'] ?? null ) ) {
$cursor = $error;
while ( $cursor instanceof \Throwable ) {
if ( $cursor instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\SerializationError ) {
$formatted['extensions']['code'] = 'BAD_USER_INPUT';
break;
}
$cursor = $cursor->getPrevious();
}
}
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, $principal );
$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( $principal, $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.
$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 );
}
/**
* Public handle to the live GraphQL schema for runtime inspection.
*
* Returns an opaque {@see SchemaHandle}; callers reach metadata (and any
* future schema-inspection operations) through methods on that object
* rather than touching the underlying engine type. The handle is cached
* and wraps the same engine schema this controller uses to serve real
* requests.
*/
public function get_schema(): SchemaHandle {
if ( null === $this->schema_handle ) {
$this->schema_handle = new SchemaHandle( $this->get_engine_schema() );
}
return $this->schema_handle;
}
/**
* Build and cache the engine-typed GraphQL schema used internally to
* serve requests. Kept private to keep the engine type out of the
* controller's public surface; consumers should reach {@see SchemaHandle}
* through {@see self::get_schema()} instead.
*/
private function get_engine_schema(): Schema {
if ( null === $this->schema ) {
$this->schema = $this->build_schema();
}
return $this->schema;
}
/**
* Construct the GraphQL schema.
*
* Implemented by the autogenerated subclass emitted by ApiBuilder
* (both for WooCommerce core and for sibling plugins that reuse this
* infrastructure) so the base class stays agnostic to any specific
* autogenerated namespace.
*/
abstract protected function build_schema(): Schema;
/**
* FQCN of the user-provided ClassResolver, or null when none was detected.
*
* The autogenerated subclass overrides this to return its plugin's
* `<api_namespace>\Infrastructure\ClassResolver` when ApiBuilder detected
* one. When null, classes are instantiated with `new $class()`.
*/
protected function get_class_resolver_fqcn(): ?string {
return null;
}
/**
* FQCN of the user-provided PrincipalResolver, or null when none was detected.
*
* The autogenerated subclass overrides this to return its plugin's
* `<api_namespace>\Infrastructure\PrincipalResolver` when ApiBuilder detected
* one. When null, the controller falls back to {@see \wp_get_current_user()}
* (anonymous → null) to populate the request principal.
*/
protected function get_principal_resolver_fqcn(): ?string {
return null;
}
/**
* Whether the configured PrincipalResolver's `resolve_principal()` declares
* the \WP_REST_Request parameter (true) or omits it (false).
*
* Captured at build time and emitted as an override on the autogenerated
* controller subclass, so the call below uses the right arity without
* runtime reflection. Default is irrelevant when {@see self::get_principal_resolver_fqcn()}
* returns null (the inline `wp_get_current_user()` fallback applies instead).
*/
protected function principal_resolver_takes_request(): bool {
return false;
}
/**
* Resolve a class to an instance via the configured ClassResolver, or `new`.
*
* Used internally to instantiate the PrincipalResolver per request. The
* autogenerated resolver classes use the detected ClassResolver directly
* (the FQCN is baked in at build time), so this helper is only the runtime
* path for infrastructure classes that the controller itself has to load.
*
* @param string $class_name Fully-qualified name of the class to resolve.
*/
private function resolve_class( string $class_name ): object {
$resolver = $this->get_class_resolver_fqcn();
if ( null === $resolver ) {
return new $class_name();
}
return $resolver::resolve_class( $class_name );
}
/**
* Resolve the request principal once per HTTP request.
*
* Invoked eagerly at the top of {@see self::process_request()}, so a
* principal resolver throwing {@see ApiException} fails the request before
* any resolver runs (single coded error in the response, no `data`).
*
* The principal is never null — anonymous requests are signalled by a
* principal whose authentication state is unauthenticated (for the default
* {@see \Automattic\WooCommerce\Api\Infrastructure\Principal}, that's
* `Principal::$user->ID === 0`). Plugin resolvers can also signal "invalid
* credentials" by throwing ApiException.
*
* The configured resolver's `resolve_principal()` may declare its
* \WP_REST_Request parameter or omit it; the autogenerated subclass
* overrides {@see self::principal_resolver_takes_request()} so this call
* uses the right arity without runtime reflection.
*
* @param \WP_REST_Request $request The incoming REST request.
* @throws ApiException When the configured PrincipalResolver rejects the request.
*/
private function resolve_request_principal( \WP_REST_Request $request ): object {
$fqcn = $this->get_principal_resolver_fqcn();
if ( null === $fqcn ) {
return new \Automattic\WooCommerce\Api\Infrastructure\Principal( wp_get_current_user() );
}
$resolver = $this->resolve_class( $fqcn );
return $this->principal_resolver_takes_request()
? $resolver->resolve_principal( $request )
: $resolver->resolve_principal();
}
/**
* 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 for the request, based on {@see self::is_debug_mode()}.
*
* @param \WP_REST_Request $request The REST request.
* @param ?object $principal The resolved principal, or null if resolution itself failed.
*/
private function get_debug_flags( \WP_REST_Request $request, ?object $principal ): int {
if ( ! $this->is_debug_mode( $principal, $request ) ) {
return DebugFlag::NONE;
}
return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
}
/**
* Check whether GraphQL introspection is allowed for this request.
*
* The principal opts in via a `can_introspect(): bool` method; principals
* that don't declare it are denied by default. The decision is then passed
* through the {@see 'woocommerce_graphql_can_introspect'} filter so sites
* can grant or revoke access without subclassing the principal — useful
* for per-request rules (specific IPs, headers, query parameters, etc.).
*
* Fail-closed contract: the principal must be non-null (principal-resolution
* failures deny outright, before the filter is consulted), the principal
* method's return value is treated with `=== true`, and any throw from
* either the principal method or the filter callback denies. The filter
* must likewise return strictly `true` to allow; any other value denies.
*
* @param ?object $principal The resolved principal, or null when principal resolution failed.
* @param \WP_REST_Request $request The REST request.
*/
private function is_introspection_allowed( ?object $principal, \WP_REST_Request $request ): bool {
if ( is_null( $principal ) ) {
return false;
}
try {
$can_introspect = method_exists( $principal, 'can_introspect' )
&& true === $principal->can_introspect();
/**
* Filters whether the current principal may run GraphQL introspection.
*
* The filter receives the principal-derived decision (false when the
* principal doesn't declare `can_introspect()` or its `can_introspect()`
* doesn't return strictly `true`) and must return strictly `true` to
* grant access; any other return value denies. The filter is not
* invoked when principal resolution failed (i.e. when the controller
* passes a null principal) — that case denies outright.
*
* @since 10.9.0
*
* @internal
*
* @param bool $can_introspect Whether the principal can introspect, derived from `$principal->can_introspect()`.
* @param object $principal The resolved principal.
* @param \WP_REST_Request $request The REST request being processed.
*/
$can_introspect = apply_filters( 'woocommerce_graphql_can_introspect', $can_introspect, $principal, $request );
} catch ( \Throwable $e ) {
return false;
}
return true === $can_introspect;
}
/**
* Check if debug mode is active.
*
* Debug mode is gated on `_debug=1` being set on the request: when absent,
* debug mode is off regardless of any other signal. When present, the
* principal opts in via a `can_use_debug_mode(): bool` method (principals
* that don't declare it are denied by default), and the decision is then
* passed through the {@see 'woocommerce_graphql_can_use_debug_mode'} filter.
*
* Fail-closed contract: the principal must be non-null (principal-resolution
* failures deny outright, before the filter is consulted), the principal
* method's return value is treated with `=== true`, and any throw from
* either the principal method or the filter callback denies. The filter
* must likewise return strictly `true` to allow; any other value denies.
*
* @param ?object $principal The resolved principal, or null when principal resolution failed.
* @param \WP_REST_Request $request The REST request.
*/
private function is_debug_mode( ?object $principal, \WP_REST_Request $request ): bool {
if ( '1' !== $request->get_param( '_debug' ) ) {
return false;
}
if ( is_null( $principal ) ) {
return false;
}
try {
$can_debug = method_exists( $principal, 'can_use_debug_mode' )
&& true === $principal->can_use_debug_mode();
/**
* Filters whether the current principal may activate GraphQL debug mode.
*
* Only invoked when the request carries `_debug=1` and a principal was
* resolved successfully, so the filter is not called on every GraphQL
* request. The filter receives the principal-derived decision (false
* when the principal doesn't declare `can_use_debug_mode()` or its
* `can_use_debug_mode()` doesn't return strictly `true`) and must
* return strictly `true` to grant access; any other return value denies.
*
* @since 10.9.0
*
* @internal
*
* @param bool $can_debug Whether the principal can use debug mode, derived from `$principal->can_use_debug_mode()`.
* @param object $principal The resolved principal.
* @param \WP_REST_Request $request The REST request being processed.
*/
$can_debug = apply_filters( 'woocommerce_graphql_can_use_debug_mode', $can_debug, $principal, $request );
} catch ( \Throwable $e ) {
return false;
}
return true === $can_debug;
}
/**
* Format a caught exception into a GraphQL error array.
*
* @param \Throwable $e The caught exception.
* @param \WP_REST_Request $request The REST request.
* @param ?object $principal The resolved principal, or null when the exception came from principal resolution itself.
*/
private function format_exception( \Throwable $e, \WP_REST_Request $request, ?object $principal ): 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( $principal, $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,
'INVALID_TOKEN' => 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, under two
* different metrics:
*
* - `tree_only`: only fields whose own selection set is non-empty count
* toward depth; leaves are excluded. This is the number directly
* comparable to the "Maximum query depth" setting's limit, and matches
* what webonyx's QueryDepth validation rule measures for the enforcement
* decision.
* - `in_depth`: counts every field in the deepest chain, leaves included.
* Useful as a shape metric when inspecting a query.
*
* Inline fragments pass through without incrementing either metric.
* Named-fragment spreads are not expanded here, so both numbers are lower
* bounds when spreads are present. The webonyx QueryDepth validation rule
* (which does expand spreads) remains the authoritative gate.
*
* @param DocumentNode $document The parsed GraphQL document.
* @param ?string $operation_name The requested operation name, if any.
* @return array{tree_only: int, in_depth: int}
*/
private function compute_query_depth( DocumentNode $document, ?string $operation_name ): array {
$tree_only = 0;
$in_depth = 0;
foreach ( $document->definitions as $definition ) {
if ( ! $definition instanceof OperationDefinitionNode ) {
continue;
}
if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) {
continue;
}
$tree_only = max( $tree_only, $this->walk_depth_tree_only( $definition->selectionSet, 0 ) );
$in_depth = max( $in_depth, $this->walk_depth_in_depth( $definition->selectionSet, 0 ) );
}
return array(
'tree_only' => $tree_only,
'in_depth' => $in_depth,
);
}
/**
* Walk a selection set counting only fields with child selections, matching
* webonyx's QueryDepth rule so the returned number is directly comparable
* to the configured "Maximum query depth" limit.
*
* @param ?SelectionSetNode $selection_set The selection set to walk.
* @param int $depth The depth at which fields in this selection set sit.
*/
private function walk_depth_tree_only( ?SelectionSetNode $selection_set, int $depth ): int {
if ( null === $selection_set ) {
return 0;
}
$max = 0;
foreach ( $selection_set->selections as $selection ) {
if ( $selection instanceof FieldNode ) {
if ( null !== $selection->selectionSet ) {
$max = max( $max, $depth, $this->walk_depth_tree_only( $selection->selectionSet, $depth + 1 ) );
}
} elseif ( $selection instanceof InlineFragmentNode ) {
$max = max( $max, $this->walk_depth_tree_only( $selection->selectionSet, $depth ) );
}
}
return $max;
}
/**
* Walk a selection set counting every field in the deepest chain, leaves
* included. Produces the "shape" metric surfaced alongside the enforcement
* metric in debug output.
*
* @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_in_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_in_depth( $selection->selectionSet, $depth + 1 ) );
} elseif ( $selection instanceof InlineFragmentNode ) {
$max = max( $max, $this->walk_depth_in_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;
}
}