WooCommerce Code Reference

GraphQLControllerBase.php

Source code

<?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;
	}
}