PATH:
home
/
thebhoeo
/
public_html
/
officepoint
/
wp-content
/
plugins
/
woocommerce
/
src
/
Api
/
Infrastructure
<?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; } }
[-] PrincipalResolver.php
[edit]
[-] Main.php
[edit]
[-] Principal.php
[edit]
[-] GraphQLControllerBase.php
[edit]
[-] ResolverHelpers.php
[edit]
[-] QueryInfoExtractor.php
[edit]
[+]
..
[-] ClassResolver.php
[edit]
[+]
Schema
[-] MetadataController.php
[edit]