Schema
4 weeks ago
ClassResolver.php
4 weeks ago
GraphQLControllerBase.php
4 weeks ago
Main.php
4 weeks ago
MetadataController.php
4 weeks ago
Principal.php
4 weeks ago
PrincipalResolver.php
4 weeks ago
QueryInfoExtractor.php
4 weeks ago
ResolverHelpers.php
4 weeks ago
GraphQLControllerBase.php
1085 lines
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Api\Infrastructure; |
| 6 | |
| 7 | use Automattic\WooCommerce\Api\ApiException; |
| 8 | use Automattic\WooCommerce\Api\Infrastructure\Schema\Schema; |
| 9 | use Automattic\WooCommerce\Api\Utils\SchemaHandle; |
| 10 | use Automattic\WooCommerce\Internal\Api\QueryCache; |
| 11 | use Automattic\WooCommerce\Internal\Api\QueryComplexityRule; |
| 12 | use Automattic\WooCommerce\Internal\Api\QueryDepthRule; |
| 13 | use Automattic\WooCommerce\Internal\Api\StatusResolverFailedException; |
| 14 | use Automattic\WooCommerce\Vendor\GraphQL\Error\DebugFlag; |
| 15 | use Automattic\WooCommerce\Vendor\GraphQL\GraphQL; |
| 16 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode; |
| 17 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode; |
| 18 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\InlineFragmentNode; |
| 19 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\OperationDefinitionNode; |
| 20 | use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode; |
| 21 | use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator; |
| 22 | use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\DisableIntrospection; |
| 23 | |
| 24 | /** |
| 25 | * Handles incoming GraphQL requests over the WooCommerce REST API. |
| 26 | * |
| 27 | * Abstract: the autogenerated `GraphQLController` subclass emitted by |
| 28 | * ApiBuilder (both for WooCommerce core and for sibling plugins reusing this |
| 29 | * infrastructure) is the concrete class. The public surface plugins extend |
| 30 | * is engine-decoupled — the abstract {@see self::build_schema()} returns |
| 31 | * {@see Schema}, a stable subclass of the underlying engine's schema type, |
| 32 | * so a future engine swap doesn't break already-committed autogen trees. |
| 33 | */ |
| 34 | abstract class GraphQLControllerBase { |
| 35 | /** |
| 36 | * Default nesting-depth limit applied when the option is unset or non-positive. |
| 37 | * |
| 38 | * Queries exceeding the configured limit are rejected during validation, |
| 39 | * before any resolver runs. See {@see self::get_max_query_depth()} for the accessor. |
| 40 | */ |
| 41 | public const DEFAULT_MAX_QUERY_DEPTH = 15; |
| 42 | |
| 43 | /** |
| 44 | * Default complexity-score limit applied when the option is unset or non-positive. |
| 45 | * |
| 46 | * Complexity is the sum of per-field scores; connection fields multiply |
| 47 | * their child score by the requested page size. Queries exceeding the |
| 48 | * configured limit are rejected during validation. See |
| 49 | * {@see self::get_max_query_complexity()} for the accessor. |
| 50 | */ |
| 51 | public const DEFAULT_MAX_QUERY_COMPLEXITY = 1000; |
| 52 | |
| 53 | /** |
| 54 | * Default path (relative to /wp-json/) at which the GraphQL route is registered. |
| 55 | * |
| 56 | * Used as the fallback when the {@see Main::OPTION_ENDPOINT_URL} option is |
| 57 | * unset or was stored in an invalid form. See {@see self::get_endpoint_url()} |
| 58 | * for the accessor. |
| 59 | */ |
| 60 | public const DEFAULT_ENDPOINT_URL = 'wc/graphql'; |
| 61 | |
| 62 | /** |
| 63 | * Regex matching one valid path segment of the endpoint URL. |
| 64 | * |
| 65 | * Constrained to the character class WordPress REST routes accept |
| 66 | * (alphanumerics, underscores, hyphens). Shared with {@see \Automattic\WooCommerce\Internal\Api\Settings::sanitize_endpoint_url()} |
| 67 | * so the UI sanitizer and the controller-side fallback stay in lockstep. |
| 68 | */ |
| 69 | public const ENDPOINT_URL_SEGMENT_PATTERN = '/^[A-Za-z0-9_\-]+$/'; |
| 70 | |
| 71 | /** |
| 72 | * Cached GraphQL schema instance. |
| 73 | * |
| 74 | * @var ?Schema |
| 75 | */ |
| 76 | private ?Schema $schema = null; |
| 77 | |
| 78 | /** |
| 79 | * Cached public-facing schema handle wrapping {@see self::$schema}. |
| 80 | * |
| 81 | * @var ?SchemaHandle |
| 82 | */ |
| 83 | private ?SchemaHandle $schema_handle = null; |
| 84 | |
| 85 | /** |
| 86 | * Query cache / APQ resolver. |
| 87 | * |
| 88 | * @var QueryCache |
| 89 | */ |
| 90 | private QueryCache $query_cache; |
| 91 | |
| 92 | /** |
| 93 | * Optional plugin-supplied HTTP status resolver. |
| 94 | * |
| 95 | * Populated from {@see self::get_status_resolver()} during {@see self::init()}. |
| 96 | * Stays null when neither this controller nor its subclass supplies one, |
| 97 | * in which case {@see self::pick_status()} short-circuits to the default |
| 98 | * status without ever calling a resolver. |
| 99 | * |
| 100 | * Typed as `?object` rather than a WooCommerce-defined interface so that |
| 101 | * sibling plugins do not have to import a WooCommerce type for what is |
| 102 | * structurally a single duck-typed method. |
| 103 | * |
| 104 | * @var ?object |
| 105 | */ |
| 106 | private ?object $status_resolver = null; |
| 107 | |
| 108 | /** |
| 109 | * DI: injected by WooCommerce container. |
| 110 | * |
| 111 | * @internal |
| 112 | * @param QueryCache $query_cache The query cache instance. |
| 113 | */ |
| 114 | final public function init( QueryCache $query_cache ): void { |
| 115 | $this->query_cache = $query_cache; |
| 116 | // Resolved through a virtual hook so autogenerated subclasses can |
| 117 | // supply a per-plugin resolver without changing init()'s signature. |
| 118 | // Late binding picks up the override; init() can stay final. |
| 119 | $this->status_resolver = $this->get_status_resolver(); |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Return the HTTP status resolver instance to use for this controller, or |
| 124 | * null to opt out. Default: null (use the framework defaults). |
| 125 | * |
| 126 | * Autogenerated subclasses override this when the plugin ships a |
| 127 | * `<plugin-api-namespace>\Infrastructure\HttpStatusResolver` convention |
| 128 | * class. The returned object is duck-typed: it must expose |
| 129 | * `public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int`, |
| 130 | * must return an int, and must not throw. A throw is treated as a plugin |
| 131 | * bug and produces a fixed 500 INTERNAL_ERROR response — see |
| 132 | * {@see self::pick_status()} and {@see self::handle_request()}. |
| 133 | */ |
| 134 | protected function get_status_resolver(): ?object { |
| 135 | return null; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * The maximum nesting depth allowed in a GraphQL query. |
| 140 | * |
| 141 | * Reads the {@see Main::OPTION_MAX_QUERY_DEPTH} store option; falls back |
| 142 | * to {@see self::DEFAULT_MAX_QUERY_DEPTH} when the option is unset, empty, |
| 143 | * or non-positive. |
| 144 | */ |
| 145 | public static function get_max_query_depth(): int { |
| 146 | $value = (int) get_option( Main::OPTION_MAX_QUERY_DEPTH, self::DEFAULT_MAX_QUERY_DEPTH ); |
| 147 | return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_DEPTH; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * The maximum computed complexity score allowed for a GraphQL query. |
| 152 | * |
| 153 | * Reads the {@see Main::OPTION_MAX_QUERY_COMPLEXITY} store option; falls |
| 154 | * back to {@see self::DEFAULT_MAX_QUERY_COMPLEXITY} when the option is |
| 155 | * unset, empty, or non-positive. |
| 156 | */ |
| 157 | public static function get_max_query_complexity(): int { |
| 158 | $value = (int) get_option( Main::OPTION_MAX_QUERY_COMPLEXITY, self::DEFAULT_MAX_QUERY_COMPLEXITY ); |
| 159 | return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_COMPLEXITY; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * The path (relative to /wp-json/) at which the GraphQL route is registered. |
| 164 | * |
| 165 | * Reads the {@see Main::OPTION_ENDPOINT_URL} store option; falls back to |
| 166 | * {@see self::DEFAULT_ENDPOINT_URL} when the option is unset, empty, or |
| 167 | * fails {@see self::is_valid_endpoint_url()}. The UI already validates on |
| 168 | * save, so this defense-in-depth guard only fires for CLI-set option values. |
| 169 | */ |
| 170 | public static function get_endpoint_url(): string { |
| 171 | $value = trim( (string) get_option( Main::OPTION_ENDPOINT_URL, self::DEFAULT_ENDPOINT_URL ), '/' ); |
| 172 | if ( ! self::is_valid_endpoint_url( $value ) ) { |
| 173 | return self::DEFAULT_ENDPOINT_URL; |
| 174 | } |
| 175 | return $value; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Whether a value is a valid endpoint URL. |
| 180 | * |
| 181 | * Requires at least two non-empty path segments (so register_rest_route() |
| 182 | * has both a namespace and a route), each matching |
| 183 | * {@see self::ENDPOINT_URL_SEGMENT_PATTERN}. Mirrors the rules enforced on |
| 184 | * save by {@see \Automattic\WooCommerce\Internal\Api\Settings::sanitize_endpoint_url()}, so values that bypass |
| 185 | * the UI (e.g. CLI-set options) get the same treatment. |
| 186 | * |
| 187 | * @param string $value Endpoint URL with surrounding slashes already stripped. |
| 188 | */ |
| 189 | private static function is_valid_endpoint_url( string $value ): bool { |
| 190 | if ( '' === $value ) { |
| 191 | return false; |
| 192 | } |
| 193 | $parts = explode( '/', $value ); |
| 194 | if ( count( $parts ) < 2 ) { |
| 195 | return false; |
| 196 | } |
| 197 | foreach ( $parts as $part ) { |
| 198 | if ( '' === $part || ! preg_match( self::ENDPOINT_URL_SEGMENT_PATTERN, $part ) ) { |
| 199 | return false; |
| 200 | } |
| 201 | } |
| 202 | return true; |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Split the endpoint URL into the `[namespace, route]` pair that |
| 207 | * register_rest_route() expects. |
| 208 | * |
| 209 | * The last path segment becomes the route; everything before it becomes |
| 210 | * the namespace. E.g. `wc/v4/graphql` → `['wc/v4', '/graphql']`. |
| 211 | * |
| 212 | * @return array{0: string, 1: string} |
| 213 | */ |
| 214 | private static function split_endpoint_url(): array { |
| 215 | $parts = explode( '/', self::get_endpoint_url() ); |
| 216 | $route = '/' . array_pop( $parts ); |
| 217 | $namespace = implode( '/', $parts ); |
| 218 | return array( $namespace, $route ); |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Register the GraphQL REST route. |
| 223 | */ |
| 224 | public function register(): void { |
| 225 | $methods = Main::filter_methods_against_settings( array( 'GET', 'POST' ) ); |
| 226 | if ( empty( $methods ) ) { |
| 227 | return; |
| 228 | } |
| 229 | list( $namespace, $route ) = self::split_endpoint_url(); |
| 230 | |
| 231 | register_rest_route( |
| 232 | $namespace, |
| 233 | $route, |
| 234 | array( |
| 235 | 'methods' => $methods, |
| 236 | 'callback' => array( $this, 'handle_request' ), |
| 237 | // Auth is handled per-query/mutation. |
| 238 | 'permission_callback' => '__return_true', |
| 239 | ) |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | /** |
| 244 | * Handle an incoming GraphQL request. |
| 245 | * |
| 246 | * Resolves the principal first so debug-mode / introspection checks can |
| 247 | * consult it from inside both `process_request()` and the top-level |
| 248 | * exception formatter. When `resolve_request_principal()` itself throws |
| 249 | * (e.g. an InvalidTokenException from a plugin's PrincipalResolver), |
| 250 | * `$principal` stays null and the resulting error response carries no |
| 251 | * debug info — by design, since the caller failed to authenticate. |
| 252 | * |
| 253 | * @param \WP_REST_Request $request The REST request. |
| 254 | */ |
| 255 | public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { |
| 256 | $principal = null; |
| 257 | try { |
| 258 | $principal = $this->resolve_request_principal( $request ); |
| 259 | return $this->process_request( $request, $principal ); |
| 260 | } catch ( StatusResolverFailedException $e ) { |
| 261 | // Resolver threw on one of the decision points inside |
| 262 | // process_request(). Produce a clean 500 without re-invoking |
| 263 | // the (broken) resolver. |
| 264 | return $this->build_resolver_failure_response( $e, $request, $principal ); |
| 265 | } catch ( \Throwable $e ) { |
| 266 | $output = array( |
| 267 | 'errors' => array( |
| 268 | $this->format_exception( $e, $request, $principal ), |
| 269 | ), |
| 270 | ); |
| 271 | |
| 272 | $default = $this->get_error_status( $output['errors'] ); |
| 273 | try { |
| 274 | $status = $this->pick_status( $default, $output, $request ); |
| 275 | } catch ( StatusResolverFailedException $e2 ) { |
| 276 | // Resolver threw specifically when handed the synthetic |
| 277 | // errors shape from this catch block. Fall through to the |
| 278 | // fixed 500; do not loop back into the resolver. |
| 279 | return $this->build_resolver_failure_response( $e2, $request, $principal ); |
| 280 | } |
| 281 | |
| 282 | return new \WP_REST_Response( $output, $status ); |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /** |
| 287 | * Build the canonical 500 response used when the HTTP status resolver |
| 288 | * throws or returns an out-of-range value. Body shape matches an |
| 289 | * unhandled internal error so callers don't need a separate path for |
| 290 | * "resolver blew up". |
| 291 | * |
| 292 | * In debug mode, attaches `extensions.debug` (message, file, line, trace) |
| 293 | * for the wrapper exception, plus an `extensions.previous` chain when the |
| 294 | * resolver itself threw — mirroring the shape that {@see self::format_exception()} |
| 295 | * produces for the generic-Throwable path. Outside debug mode the body |
| 296 | * stays purely generic so resolver internals never leak to anonymous callers. |
| 297 | * |
| 298 | * @param StatusResolverFailedException $e The wrapper exception thrown by {@see self::pick_status()}. |
| 299 | * @param \WP_REST_Request $request The originating REST request. |
| 300 | * @param ?object $principal The resolved principal, or null when resolution failed. |
| 301 | */ |
| 302 | private function build_resolver_failure_response( |
| 303 | StatusResolverFailedException $e, |
| 304 | \WP_REST_Request $request, |
| 305 | ?object $principal |
| 306 | ): \WP_REST_Response { |
| 307 | $error = array( |
| 308 | 'message' => 'An unexpected error occurred.', |
| 309 | 'extensions' => array( 'code' => 'INTERNAL_ERROR' ), |
| 310 | ); |
| 311 | |
| 312 | if ( $this->is_debug_mode( $principal, $request ) ) { |
| 313 | $error['extensions']['debug'] = array( |
| 314 | 'message' => $e->getMessage(), |
| 315 | 'file' => $e->getFile(), |
| 316 | 'line' => $e->getLine(), |
| 317 | 'trace' => $e->getTraceAsString(), |
| 318 | ); |
| 319 | |
| 320 | $chain = $this->extract_previous_chain( $e ); |
| 321 | if ( ! empty( $chain ) ) { |
| 322 | $error['extensions']['previous'] = $chain; |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | return new \WP_REST_Response( array( 'errors' => array( $error ) ), 500 ); |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Filter the framework-computed default HTTP status through the optional |
| 331 | * plugin-supplied status resolver. |
| 332 | * |
| 333 | * When no resolver is configured this returns the default verbatim. When |
| 334 | * a resolver is configured its return value (an int) is returned in |
| 335 | * place of the default — which the resolver may also pass through |
| 336 | * unchanged for cases it does not want to override. |
| 337 | * |
| 338 | * Resolver-thrown exceptions are converted into an internal |
| 339 | * {@see StatusResolverFailedException} for {@see self::handle_request()} |
| 340 | * to handle, so a plugin bug never corrupts or duplicates a response. |
| 341 | * |
| 342 | * @param int $default The framework-computed default status. |
| 343 | * @param array $output The response body about to be sent (may include `errors`/`data`). |
| 344 | * @param \WP_REST_Request $request The originating request. |
| 345 | * |
| 346 | * @throws StatusResolverFailedException When the resolver throws or returns a status code outside the 100..599 HTTP range. |
| 347 | */ |
| 348 | private function pick_status( int $default, array $output, \WP_REST_Request $request ): int { |
| 349 | if ( null === $this->status_resolver ) { |
| 350 | return $default; |
| 351 | } |
| 352 | // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal sentinel; never serialised to the wire. |
| 353 | try { |
| 354 | $resolved = $this->status_resolver->resolve_status( $default, $output, $request ); |
| 355 | } catch ( \Throwable $e ) { |
| 356 | throw new StatusResolverFailedException( 'HTTP status resolver threw.', 0, $e ); |
| 357 | } |
| 358 | // Guard against nonsensical return values. Range-checking outside the |
| 359 | // try/catch keeps this exception out of the generic-Throwable wrap |
| 360 | // above, so a bad return value surfaces as the same fixed-shape 500 |
| 361 | // response as a throw — never as a malformed WP_REST_Response. |
| 362 | if ( $resolved < 100 || $resolved > 599 ) { |
| 363 | throw new StatusResolverFailedException( |
| 364 | sprintf( 'HTTP status resolver returned an out-of-range status code: %d.', $resolved ) |
| 365 | ); |
| 366 | } |
| 367 | // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 368 | return $resolved; |
| 369 | } |
| 370 | |
| 371 | /** |
| 372 | * Process the GraphQL request. Extracted so that handle_request() can |
| 373 | * wrap everything in a single try/catch that respects debug mode. |
| 374 | * |
| 375 | * @param \WP_REST_Request $request The REST request. |
| 376 | * @param object $principal The principal resolved by handle_request(); never null when this is reached. |
| 377 | */ |
| 378 | private function process_request( \WP_REST_Request $request, object $principal ): \WP_REST_Response { |
| 379 | // 2. Parse request. GET query-string `variables` and `extensions` |
| 380 | // arrive as JSON strings; decode_json_param() unifies them with the |
| 381 | // already-decoded-array path from POST bodies and rejects malformed |
| 382 | // or non-object payloads up front so they surface as HTTP 400 |
| 383 | // INVALID_ARGUMENT instead of as confusing resolver errors (null |
| 384 | // decode) or HTTP 500 TypeErrors (scalar decode). |
| 385 | $query = $request->get_param( 'query' ); |
| 386 | $operation_name = $request->get_param( 'operationName' ); |
| 387 | $variables = $this->decode_json_param( $request->get_param( 'variables' ), 'variables' ); |
| 388 | $extensions = $this->decode_json_param( $request->get_param( 'extensions' ), 'extensions' ); |
| 389 | |
| 390 | // 3. Resolve query (cache lookup / APQ / parse). |
| 391 | $source = $this->query_cache->resolve( $query, $extensions ); |
| 392 | if ( is_array( $source ) ) { |
| 393 | $default = $this->get_resolve_error_status( $source ); |
| 394 | return new \WP_REST_Response( $source, $this->pick_status( $default, $source, $request ) ); |
| 395 | } |
| 396 | |
| 397 | // 4. Reject mutations over GET (GraphQL over HTTP spec). |
| 398 | if ( 'GET' === $request->get_method() && $this->document_has_mutation( $source, $operation_name ) ) { |
| 399 | $method_not_allowed_output = array( |
| 400 | 'errors' => array( |
| 401 | array( |
| 402 | 'message' => 'Mutations are not allowed over GET requests. Use POST instead.', |
| 403 | 'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ), |
| 404 | ), |
| 405 | ), |
| 406 | ); |
| 407 | return new \WP_REST_Response( |
| 408 | $method_not_allowed_output, |
| 409 | $this->pick_status( 405, $method_not_allowed_output, $request ) |
| 410 | ); |
| 411 | } |
| 412 | |
| 413 | // 5. Load schema. |
| 414 | $schema = $this->get_engine_schema(); |
| 415 | |
| 416 | // 6. Build validation rules. |
| 417 | // A single complexity-rule instance is kept so its computed score can |
| 418 | // be surfaced in the debug extensions after execution. |
| 419 | $complexity_rule = new QueryComplexityRule( self::get_max_query_complexity() ); |
| 420 | $validation_rules = array_values( DocumentValidator::allRules() ); |
| 421 | $validation_rules[] = new QueryDepthRule( self::get_max_query_depth() ); |
| 422 | $validation_rules[] = $complexity_rule; |
| 423 | if ( ! $this->is_introspection_allowed( $principal, $request ) ) { |
| 424 | $validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED ); |
| 425 | } |
| 426 | |
| 427 | // 7. Execute. The context value is an ArrayObject (not a plain array) |
| 428 | // so root resolvers can mutate it — specifically to thread the root |
| 429 | // query's metadata into `$context['_query_metadata']` for downstream |
| 430 | // field-level authorization gates. ArrayObject preserves the |
| 431 | // `$context['key']` read syntax via ArrayAccess. The context carries |
| 432 | // the resolved principal through to autogenerated resolvers, which |
| 433 | // expose it as the `_principal` infrastructure parameter when commands |
| 434 | // declare it on their authorize()/execute() methods. Request-derived |
| 435 | // data that resolvers need is carried by the principal class itself — |
| 436 | // populated by the PrincipalResolver, the only component wired to the |
| 437 | // HTTP transport. |
| 438 | $result = GraphQL::executeQuery( |
| 439 | schema: $schema, |
| 440 | source: $source, |
| 441 | contextValue: new \ArrayObject( |
| 442 | array( |
| 443 | 'principal' => $principal, |
| 444 | ) |
| 445 | ), |
| 446 | variableValues: $variables, |
| 447 | operationName: $operation_name, |
| 448 | validationRules: $validation_rules, |
| 449 | ); |
| 450 | |
| 451 | // Install an error formatter that guarantees every error carries an |
| 452 | // `extensions.code`. Our resolvers route everything through |
| 453 | // Utils::execute_command / Utils::authorize_command, which already |
| 454 | // translate domain exceptions (ApiException, InvalidArgumentException, |
| 455 | // generic Throwable) into coded GraphQL errors at the throw site. |
| 456 | // What reaches us uncoded here is webonyx-native validation and |
| 457 | // execution output, so we infer from webonyx's ClientAware signal: |
| 458 | // client-safe errors become BAD_USER_INPUT (400), the rest become |
| 459 | // INTERNAL_ERROR (500). |
| 460 | // |
| 461 | // In debug mode the same formatter also walks the previous-exception |
| 462 | // chain so wrapped errors (e.g. a \ValueError caught by a resolver and |
| 463 | // re-thrown as INTERNAL_ERROR) stay visible to the developer instead |
| 464 | // of being masked behind the generic "Internal server error" message. |
| 465 | $debug_mode = $this->is_debug_mode( $principal, $request ); |
| 466 | $result->setErrorFormatter( |
| 467 | function ( \Throwable $error ) use ( $debug_mode ): array { |
| 468 | $formatted = \Automattic\WooCommerce\Vendor\GraphQL\Error\FormattedError::createFromException( $error ); |
| 469 | |
| 470 | if ( ! isset( $formatted['extensions']['code'] ) ) { |
| 471 | $client_safe = $error instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\ClientAware && $error->isClientSafe(); |
| 472 | $formatted['extensions']['code'] = $client_safe ? 'BAD_USER_INPUT' : 'INTERNAL_ERROR'; |
| 473 | } |
| 474 | |
| 475 | // SerializationError (thrown during schema-type coercion, e.g. when |
| 476 | // a resolver returns an Int that doesn't fit 32 bits) extends |
| 477 | // \Exception rather than webonyx's ClientAware Error, so it lands |
| 478 | // in the INTERNAL_ERROR bucket above. Its message is actually |
| 479 | // client-actionable ("value out of range — send smaller inputs"), |
| 480 | // so promote it to BAD_USER_INPUT when it shows up anywhere in |
| 481 | // the previous-exception chain. |
| 482 | if ( 'BAD_USER_INPUT' !== ( $formatted['extensions']['code'] ?? null ) ) { |
| 483 | $cursor = $error; |
| 484 | while ( $cursor instanceof \Throwable ) { |
| 485 | if ( $cursor instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\SerializationError ) { |
| 486 | $formatted['extensions']['code'] = 'BAD_USER_INPUT'; |
| 487 | break; |
| 488 | } |
| 489 | $cursor = $cursor->getPrevious(); |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | if ( $debug_mode ) { |
| 494 | $chain = $this->extract_previous_chain( $error ); |
| 495 | if ( ! empty( $chain ) ) { |
| 496 | $formatted['extensions']['previous'] = $chain; |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | return $formatted; |
| 501 | } |
| 502 | ); |
| 503 | |
| 504 | $debug_flags = $this->get_debug_flags( $request, $principal ); |
| 505 | $output = $result->toArray( $debug_flags ); |
| 506 | |
| 507 | // 8. Debug-mode metrics: expose the computed complexity and depth so |
| 508 | // clients tuning queries can see what the server scored the request at. |
| 509 | if ( $this->is_debug_mode( $principal, $request ) ) { |
| 510 | if ( ! isset( $output['extensions'] ) ) { |
| 511 | $output['extensions'] = array(); |
| 512 | } |
| 513 | if ( ! isset( $output['extensions']['debug'] ) ) { |
| 514 | $output['extensions']['debug'] = array(); |
| 515 | } |
| 516 | $output['extensions']['debug']['complexity'] = $complexity_rule->getQueryComplexity(); |
| 517 | $output['extensions']['debug']['depth'] = $this->compute_query_depth( $source, $operation_name ); |
| 518 | } |
| 519 | |
| 520 | // 9. Determine HTTP status code. GraphQL emits `data: { field: null }` |
| 521 | // for nullable root fields even when the resolver errored, so gating |
| 522 | // the status override on `data` being absent would leave nearly every |
| 523 | // error response on HTTP 200. Always derive the status from the |
| 524 | // errors array when one is present — clients that need "200 with |
| 525 | // partial data" semantics can still read the `errors` array. |
| 526 | $default = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200; |
| 527 | $status = $this->pick_status( $default, $output, $request ); |
| 528 | |
| 529 | return new \WP_REST_Response( $output, $status ); |
| 530 | } |
| 531 | |
| 532 | /** |
| 533 | * Public handle to the live GraphQL schema for runtime inspection. |
| 534 | * |
| 535 | * Returns an opaque {@see SchemaHandle}; callers reach metadata (and any |
| 536 | * future schema-inspection operations) through methods on that object |
| 537 | * rather than touching the underlying engine type. The handle is cached |
| 538 | * and wraps the same engine schema this controller uses to serve real |
| 539 | * requests. |
| 540 | */ |
| 541 | public function get_schema(): SchemaHandle { |
| 542 | if ( null === $this->schema_handle ) { |
| 543 | $this->schema_handle = new SchemaHandle( $this->get_engine_schema() ); |
| 544 | } |
| 545 | return $this->schema_handle; |
| 546 | } |
| 547 | |
| 548 | /** |
| 549 | * Build and cache the engine-typed GraphQL schema used internally to |
| 550 | * serve requests. Kept private to keep the engine type out of the |
| 551 | * controller's public surface; consumers should reach {@see SchemaHandle} |
| 552 | * through {@see self::get_schema()} instead. |
| 553 | */ |
| 554 | private function get_engine_schema(): Schema { |
| 555 | if ( null === $this->schema ) { |
| 556 | $this->schema = $this->build_schema(); |
| 557 | } |
| 558 | return $this->schema; |
| 559 | } |
| 560 | |
| 561 | /** |
| 562 | * Construct the GraphQL schema. |
| 563 | * |
| 564 | * Implemented by the autogenerated subclass emitted by ApiBuilder |
| 565 | * (both for WooCommerce core and for sibling plugins that reuse this |
| 566 | * infrastructure) so the base class stays agnostic to any specific |
| 567 | * autogenerated namespace. |
| 568 | */ |
| 569 | abstract protected function build_schema(): Schema; |
| 570 | |
| 571 | /** |
| 572 | * FQCN of the user-provided ClassResolver, or null when none was detected. |
| 573 | * |
| 574 | * The autogenerated subclass overrides this to return its plugin's |
| 575 | * `<api_namespace>\Infrastructure\ClassResolver` when ApiBuilder detected |
| 576 | * one. When null, classes are instantiated with `new $class()`. |
| 577 | */ |
| 578 | protected function get_class_resolver_fqcn(): ?string { |
| 579 | return null; |
| 580 | } |
| 581 | |
| 582 | /** |
| 583 | * FQCN of the user-provided PrincipalResolver, or null when none was detected. |
| 584 | * |
| 585 | * The autogenerated subclass overrides this to return its plugin's |
| 586 | * `<api_namespace>\Infrastructure\PrincipalResolver` when ApiBuilder detected |
| 587 | * one. When null, the controller falls back to {@see \wp_get_current_user()} |
| 588 | * (anonymous → null) to populate the request principal. |
| 589 | */ |
| 590 | protected function get_principal_resolver_fqcn(): ?string { |
| 591 | return null; |
| 592 | } |
| 593 | |
| 594 | /** |
| 595 | * Whether the configured PrincipalResolver's `resolve_principal()` declares |
| 596 | * the \WP_REST_Request parameter (true) or omits it (false). |
| 597 | * |
| 598 | * Captured at build time and emitted as an override on the autogenerated |
| 599 | * controller subclass, so the call below uses the right arity without |
| 600 | * runtime reflection. Default is irrelevant when {@see self::get_principal_resolver_fqcn()} |
| 601 | * returns null (the inline `wp_get_current_user()` fallback applies instead). |
| 602 | */ |
| 603 | protected function principal_resolver_takes_request(): bool { |
| 604 | return false; |
| 605 | } |
| 606 | |
| 607 | /** |
| 608 | * Resolve a class to an instance via the configured ClassResolver, or `new`. |
| 609 | * |
| 610 | * Used internally to instantiate the PrincipalResolver per request. The |
| 611 | * autogenerated resolver classes use the detected ClassResolver directly |
| 612 | * (the FQCN is baked in at build time), so this helper is only the runtime |
| 613 | * path for infrastructure classes that the controller itself has to load. |
| 614 | * |
| 615 | * @param string $class_name Fully-qualified name of the class to resolve. |
| 616 | */ |
| 617 | private function resolve_class( string $class_name ): object { |
| 618 | $resolver = $this->get_class_resolver_fqcn(); |
| 619 | if ( null === $resolver ) { |
| 620 | return new $class_name(); |
| 621 | } |
| 622 | return $resolver::resolve_class( $class_name ); |
| 623 | } |
| 624 | |
| 625 | /** |
| 626 | * Resolve the request principal once per HTTP request. |
| 627 | * |
| 628 | * Invoked eagerly at the top of {@see self::process_request()}, so a |
| 629 | * principal resolver throwing {@see ApiException} fails the request before |
| 630 | * any resolver runs (single coded error in the response, no `data`). |
| 631 | * |
| 632 | * The principal is never null — anonymous requests are signalled by a |
| 633 | * principal whose authentication state is unauthenticated (for the default |
| 634 | * {@see \Automattic\WooCommerce\Api\Infrastructure\Principal}, that's |
| 635 | * `Principal::$user->ID === 0`). Plugin resolvers can also signal "invalid |
| 636 | * credentials" by throwing ApiException. |
| 637 | * |
| 638 | * The configured resolver's `resolve_principal()` may declare its |
| 639 | * \WP_REST_Request parameter or omit it; the autogenerated subclass |
| 640 | * overrides {@see self::principal_resolver_takes_request()} so this call |
| 641 | * uses the right arity without runtime reflection. |
| 642 | * |
| 643 | * @param \WP_REST_Request $request The incoming REST request. |
| 644 | * @throws ApiException When the configured PrincipalResolver rejects the request. |
| 645 | */ |
| 646 | private function resolve_request_principal( \WP_REST_Request $request ): object { |
| 647 | $fqcn = $this->get_principal_resolver_fqcn(); |
| 648 | if ( null === $fqcn ) { |
| 649 | return new \Automattic\WooCommerce\Api\Infrastructure\Principal( wp_get_current_user() ); |
| 650 | } |
| 651 | $resolver = $this->resolve_class( $fqcn ); |
| 652 | return $this->principal_resolver_takes_request() |
| 653 | ? $resolver->resolve_principal( $request ) |
| 654 | : $resolver->resolve_principal(); |
| 655 | } |
| 656 | |
| 657 | /** |
| 658 | * Decode an optional JSON-object param (`variables` / `extensions`) into an array. |
| 659 | * |
| 660 | * WP_REST_Request delivers POST-body params as already-decoded arrays, |
| 661 | * but GET query-string equivalents arrive as raw JSON strings. This |
| 662 | * helper unifies the two and rejects malformed JSON or non-object |
| 663 | * payloads with an InvalidArgumentException — which handle_request() |
| 664 | * surfaces as HTTP 400 INVALID_ARGUMENT, rather than letting a null |
| 665 | * decode slip through as "no variables" or a scalar decode trigger a |
| 666 | * downstream TypeError / HTTP 500. |
| 667 | * |
| 668 | * @param mixed $value The param value from WP_REST_Request::get_param(). |
| 669 | * @param string $name The param name, used in error messages. |
| 670 | * @return array The decoded object, or an empty array when the param is omitted / empty / JSON null. |
| 671 | * @throws \InvalidArgumentException When the payload is not a JSON object or not valid JSON. |
| 672 | */ |
| 673 | private function decode_json_param( $value, string $name ): array { |
| 674 | if ( null === $value ) { |
| 675 | return array(); |
| 676 | } |
| 677 | if ( is_array( $value ) ) { |
| 678 | return $value; |
| 679 | } |
| 680 | // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON. |
| 681 | if ( ! is_string( $value ) ) { |
| 682 | throw new \InvalidArgumentException( |
| 683 | sprintf( 'Argument `%s` must be a JSON object or omitted.', $name ) |
| 684 | ); |
| 685 | } |
| 686 | if ( '' === $value ) { |
| 687 | return array(); |
| 688 | } |
| 689 | $decoded = json_decode( $value, true ); |
| 690 | if ( JSON_ERROR_NONE !== json_last_error() ) { |
| 691 | throw new \InvalidArgumentException( |
| 692 | sprintf( 'Argument `%s` is not valid JSON: %s', $name, json_last_error_msg() ) |
| 693 | ); |
| 694 | } |
| 695 | if ( null === $decoded ) { |
| 696 | // Literal "null" JSON payload — treat as omitted. |
| 697 | return array(); |
| 698 | } |
| 699 | if ( ! is_array( $decoded ) ) { |
| 700 | throw new \InvalidArgumentException( |
| 701 | sprintf( 'Argument `%s` must be a JSON object (got %s).', $name, gettype( $decoded ) ) |
| 702 | ); |
| 703 | } |
| 704 | return $decoded; |
| 705 | // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 706 | } |
| 707 | |
| 708 | /** |
| 709 | * Determine debug flags for the request, based on {@see self::is_debug_mode()}. |
| 710 | * |
| 711 | * @param \WP_REST_Request $request The REST request. |
| 712 | * @param ?object $principal The resolved principal, or null if resolution itself failed. |
| 713 | */ |
| 714 | private function get_debug_flags( \WP_REST_Request $request, ?object $principal ): int { |
| 715 | if ( ! $this->is_debug_mode( $principal, $request ) ) { |
| 716 | return DebugFlag::NONE; |
| 717 | } |
| 718 | return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE; |
| 719 | } |
| 720 | |
| 721 | /** |
| 722 | * Check whether GraphQL introspection is allowed for this request. |
| 723 | * |
| 724 | * The principal opts in via a `can_introspect(): bool` method; principals |
| 725 | * that don't declare it are denied by default. The decision is then passed |
| 726 | * through the {@see 'woocommerce_graphql_can_introspect'} filter so sites |
| 727 | * can grant or revoke access without subclassing the principal — useful |
| 728 | * for per-request rules (specific IPs, headers, query parameters, etc.). |
| 729 | * |
| 730 | * Fail-closed contract: the principal must be non-null (principal-resolution |
| 731 | * failures deny outright, before the filter is consulted), the principal |
| 732 | * method's return value is treated with `=== true`, and any throw from |
| 733 | * either the principal method or the filter callback denies. The filter |
| 734 | * must likewise return strictly `true` to allow; any other value denies. |
| 735 | * |
| 736 | * @param ?object $principal The resolved principal, or null when principal resolution failed. |
| 737 | * @param \WP_REST_Request $request The REST request. |
| 738 | */ |
| 739 | private function is_introspection_allowed( ?object $principal, \WP_REST_Request $request ): bool { |
| 740 | if ( is_null( $principal ) ) { |
| 741 | return false; |
| 742 | } |
| 743 | |
| 744 | try { |
| 745 | $can_introspect = method_exists( $principal, 'can_introspect' ) |
| 746 | && true === $principal->can_introspect(); |
| 747 | |
| 748 | /** |
| 749 | * Filters whether the current principal may run GraphQL introspection. |
| 750 | * |
| 751 | * The filter receives the principal-derived decision (false when the |
| 752 | * principal doesn't declare `can_introspect()` or its `can_introspect()` |
| 753 | * doesn't return strictly `true`) and must return strictly `true` to |
| 754 | * grant access; any other return value denies. The filter is not |
| 755 | * invoked when principal resolution failed (i.e. when the controller |
| 756 | * passes a null principal) — that case denies outright. |
| 757 | * |
| 758 | * @since 10.9.0 |
| 759 | * |
| 760 | * @internal |
| 761 | * |
| 762 | * @param bool $can_introspect Whether the principal can introspect, derived from `$principal->can_introspect()`. |
| 763 | * @param object $principal The resolved principal. |
| 764 | * @param \WP_REST_Request $request The REST request being processed. |
| 765 | */ |
| 766 | $can_introspect = apply_filters( 'woocommerce_graphql_can_introspect', $can_introspect, $principal, $request ); |
| 767 | } catch ( \Throwable $e ) { |
| 768 | return false; |
| 769 | } |
| 770 | |
| 771 | return true === $can_introspect; |
| 772 | } |
| 773 | |
| 774 | /** |
| 775 | * Check if debug mode is active. |
| 776 | * |
| 777 | * Debug mode is gated on `_debug=1` being set on the request: when absent, |
| 778 | * debug mode is off regardless of any other signal. When present, the |
| 779 | * principal opts in via a `can_use_debug_mode(): bool` method (principals |
| 780 | * that don't declare it are denied by default), and the decision is then |
| 781 | * passed through the {@see 'woocommerce_graphql_can_use_debug_mode'} filter. |
| 782 | * |
| 783 | * Fail-closed contract: the principal must be non-null (principal-resolution |
| 784 | * failures deny outright, before the filter is consulted), the principal |
| 785 | * method's return value is treated with `=== true`, and any throw from |
| 786 | * either the principal method or the filter callback denies. The filter |
| 787 | * must likewise return strictly `true` to allow; any other value denies. |
| 788 | * |
| 789 | * @param ?object $principal The resolved principal, or null when principal resolution failed. |
| 790 | * @param \WP_REST_Request $request The REST request. |
| 791 | */ |
| 792 | private function is_debug_mode( ?object $principal, \WP_REST_Request $request ): bool { |
| 793 | if ( '1' !== $request->get_param( '_debug' ) ) { |
| 794 | return false; |
| 795 | } |
| 796 | if ( is_null( $principal ) ) { |
| 797 | return false; |
| 798 | } |
| 799 | |
| 800 | try { |
| 801 | $can_debug = method_exists( $principal, 'can_use_debug_mode' ) |
| 802 | && true === $principal->can_use_debug_mode(); |
| 803 | |
| 804 | /** |
| 805 | * Filters whether the current principal may activate GraphQL debug mode. |
| 806 | * |
| 807 | * Only invoked when the request carries `_debug=1` and a principal was |
| 808 | * resolved successfully, so the filter is not called on every GraphQL |
| 809 | * request. The filter receives the principal-derived decision (false |
| 810 | * when the principal doesn't declare `can_use_debug_mode()` or its |
| 811 | * `can_use_debug_mode()` doesn't return strictly `true`) and must |
| 812 | * return strictly `true` to grant access; any other return value denies. |
| 813 | * |
| 814 | * @since 10.9.0 |
| 815 | * |
| 816 | * @internal |
| 817 | * |
| 818 | * @param bool $can_debug Whether the principal can use debug mode, derived from `$principal->can_use_debug_mode()`. |
| 819 | * @param object $principal The resolved principal. |
| 820 | * @param \WP_REST_Request $request The REST request being processed. |
| 821 | */ |
| 822 | $can_debug = apply_filters( 'woocommerce_graphql_can_use_debug_mode', $can_debug, $principal, $request ); |
| 823 | } catch ( \Throwable $e ) { |
| 824 | return false; |
| 825 | } |
| 826 | |
| 827 | return true === $can_debug; |
| 828 | } |
| 829 | |
| 830 | /** |
| 831 | * Format a caught exception into a GraphQL error array. |
| 832 | * |
| 833 | * @param \Throwable $e The caught exception. |
| 834 | * @param \WP_REST_Request $request The REST request. |
| 835 | * @param ?object $principal The resolved principal, or null when the exception came from principal resolution itself. |
| 836 | */ |
| 837 | private function format_exception( \Throwable $e, \WP_REST_Request $request, ?object $principal ): array { |
| 838 | if ( $e instanceof ApiException ) { |
| 839 | // Caller-supplied extensions come first so the canonical |
| 840 | // getErrorCode() can't be silently overridden by an extensions |
| 841 | // entry keyed 'code'. Mirrors the same invariant enforced by |
| 842 | // Utils::translate_exceptions() for the execute/authorize paths. |
| 843 | $error = array( |
| 844 | 'message' => $e->getMessage(), |
| 845 | 'extensions' => array_merge( |
| 846 | $e->getExtensions(), |
| 847 | array( 'code' => $e->getErrorCode() ) |
| 848 | ), |
| 849 | ); |
| 850 | } elseif ( $e instanceof \InvalidArgumentException ) { |
| 851 | $error = array( |
| 852 | 'message' => $e->getMessage(), |
| 853 | 'extensions' => array( 'code' => 'INVALID_ARGUMENT' ), |
| 854 | ); |
| 855 | } else { |
| 856 | $error = array( |
| 857 | 'message' => 'An unexpected error occurred.', |
| 858 | 'extensions' => array( 'code' => 'INTERNAL_ERROR' ), |
| 859 | ); |
| 860 | } |
| 861 | |
| 862 | if ( $this->is_debug_mode( $principal, $request ) ) { |
| 863 | $error['extensions']['debug'] = array( |
| 864 | 'message' => $e->getMessage(), |
| 865 | 'file' => $e->getFile(), |
| 866 | 'line' => $e->getLine(), |
| 867 | 'trace' => $e->getTraceAsString(), |
| 868 | ); |
| 869 | |
| 870 | $chain = $this->extract_previous_chain( $e ); |
| 871 | if ( ! empty( $chain ) ) { |
| 872 | $error['extensions']['debug']['previous'] = $chain; |
| 873 | } |
| 874 | } |
| 875 | |
| 876 | return $error; |
| 877 | } |
| 878 | |
| 879 | /** |
| 880 | * Walk the `getPrevious()` chain of a Throwable and return one entry per |
| 881 | * wrapped exception. Used in debug mode so that resolver-level wrappers |
| 882 | * (which bury the real cause behind a generic "INTERNAL_ERROR") still |
| 883 | * surface the underlying class/message/file/line/trace. |
| 884 | * |
| 885 | * @param \Throwable $e The outermost exception. |
| 886 | * @return array<int, array{class: string, message: string, file: string, line: int, trace: string[]}> |
| 887 | */ |
| 888 | private function extract_previous_chain( \Throwable $e ): array { |
| 889 | $chain = array(); |
| 890 | for ( $prev = $e->getPrevious(); null !== $prev; $prev = $prev->getPrevious() ) { |
| 891 | $chain[] = array( |
| 892 | 'class' => get_class( $prev ), |
| 893 | 'message' => $prev->getMessage(), |
| 894 | 'file' => $prev->getFile(), |
| 895 | 'line' => $prev->getLine(), |
| 896 | 'trace' => explode( "\n", $prev->getTraceAsString() ), |
| 897 | ); |
| 898 | } |
| 899 | return $chain; |
| 900 | } |
| 901 | |
| 902 | /** |
| 903 | * Mapping from machine-readable error codes to HTTP status codes. |
| 904 | * |
| 905 | * Any code not listed here defaults to 500, so unknown/unrecognised codes |
| 906 | * from third-party resolvers stay on the safe side. The error formatter |
| 907 | * installed in process_request() guarantees every error carries a code |
| 908 | * from this table before get_error_status() inspects it. |
| 909 | */ |
| 910 | private const ERROR_STATUS_MAP = array( |
| 911 | 'UNAUTHORIZED' => 401, |
| 912 | 'INVALID_TOKEN' => 401, |
| 913 | 'FORBIDDEN' => 403, |
| 914 | 'NOT_FOUND' => 404, |
| 915 | 'METHOD_NOT_ALLOWED' => 405, |
| 916 | 'INVALID_ARGUMENT' => 400, |
| 917 | 'BAD_USER_INPUT' => 400, |
| 918 | 'GRAPHQL_PARSE_ERROR' => 400, |
| 919 | 'GRAPHQL_PARSE_FAILED' => 400, |
| 920 | 'GRAPHQL_VALIDATION_FAILED' => 400, |
| 921 | 'VALIDATION_ERROR' => 422, |
| 922 | 'INTERNAL_ERROR' => 500, |
| 923 | ); |
| 924 | |
| 925 | /** |
| 926 | * Determine the HTTP status code from an array of GraphQL errors. |
| 927 | * |
| 928 | * Applies the code-to-status lookup to each error and returns the worst |
| 929 | * (highest) status seen. A single genuine 5xx among mixed errors surfaces |
| 930 | * as 500, which is the more useful signal for monitoring and logs. |
| 931 | * |
| 932 | * @param array $errors The GraphQL errors array. |
| 933 | */ |
| 934 | private function get_error_status( array $errors ): int { |
| 935 | $status = 200; |
| 936 | foreach ( $errors as $error ) { |
| 937 | $code = $error['extensions']['code'] ?? null; |
| 938 | $mapped = self::ERROR_STATUS_MAP[ $code ] ?? 500; |
| 939 | if ( $mapped > $status ) { |
| 940 | $status = $mapped; |
| 941 | } |
| 942 | } |
| 943 | return $status; |
| 944 | } |
| 945 | |
| 946 | /** |
| 947 | * Determine the HTTP status code for an error returned by QueryCache::resolve(). |
| 948 | * |
| 949 | * PERSISTED_QUERY_NOT_FOUND uses 200 per the Apollo APQ convention (protocol signal, not error). |
| 950 | * |
| 951 | * @param array $response The error response array from resolve(). |
| 952 | */ |
| 953 | private function get_resolve_error_status( array $response ): int { |
| 954 | $code = $response['errors'][0]['extensions']['code'] ?? ''; |
| 955 | |
| 956 | if ( 'PERSISTED_QUERY_NOT_FOUND' === $code ) { |
| 957 | return 200; |
| 958 | } |
| 959 | |
| 960 | return 400; |
| 961 | } |
| 962 | |
| 963 | /** |
| 964 | * Compute the maximum nesting depth of the executing operation, under two |
| 965 | * different metrics: |
| 966 | * |
| 967 | * - `tree_only`: only fields whose own selection set is non-empty count |
| 968 | * toward depth; leaves are excluded. This is the number directly |
| 969 | * comparable to the "Maximum query depth" setting's limit, and matches |
| 970 | * what webonyx's QueryDepth validation rule measures for the enforcement |
| 971 | * decision. |
| 972 | * - `in_depth`: counts every field in the deepest chain, leaves included. |
| 973 | * Useful as a shape metric when inspecting a query. |
| 974 | * |
| 975 | * Inline fragments pass through without incrementing either metric. |
| 976 | * Named-fragment spreads are not expanded here, so both numbers are lower |
| 977 | * bounds when spreads are present. The webonyx QueryDepth validation rule |
| 978 | * (which does expand spreads) remains the authoritative gate. |
| 979 | * |
| 980 | * @param DocumentNode $document The parsed GraphQL document. |
| 981 | * @param ?string $operation_name The requested operation name, if any. |
| 982 | * @return array{tree_only: int, in_depth: int} |
| 983 | */ |
| 984 | private function compute_query_depth( DocumentNode $document, ?string $operation_name ): array { |
| 985 | $tree_only = 0; |
| 986 | $in_depth = 0; |
| 987 | foreach ( $document->definitions as $definition ) { |
| 988 | if ( ! $definition instanceof OperationDefinitionNode ) { |
| 989 | continue; |
| 990 | } |
| 991 | |
| 992 | if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) { |
| 993 | continue; |
| 994 | } |
| 995 | |
| 996 | $tree_only = max( $tree_only, $this->walk_depth_tree_only( $definition->selectionSet, 0 ) ); |
| 997 | $in_depth = max( $in_depth, $this->walk_depth_in_depth( $definition->selectionSet, 0 ) ); |
| 998 | } |
| 999 | |
| 1000 | return array( |
| 1001 | 'tree_only' => $tree_only, |
| 1002 | 'in_depth' => $in_depth, |
| 1003 | ); |
| 1004 | } |
| 1005 | |
| 1006 | /** |
| 1007 | * Walk a selection set counting only fields with child selections, matching |
| 1008 | * webonyx's QueryDepth rule so the returned number is directly comparable |
| 1009 | * to the configured "Maximum query depth" limit. |
| 1010 | * |
| 1011 | * @param ?SelectionSetNode $selection_set The selection set to walk. |
| 1012 | * @param int $depth The depth at which fields in this selection set sit. |
| 1013 | */ |
| 1014 | private function walk_depth_tree_only( ?SelectionSetNode $selection_set, int $depth ): int { |
| 1015 | if ( null === $selection_set ) { |
| 1016 | return 0; |
| 1017 | } |
| 1018 | |
| 1019 | $max = 0; |
| 1020 | foreach ( $selection_set->selections as $selection ) { |
| 1021 | if ( $selection instanceof FieldNode ) { |
| 1022 | if ( null !== $selection->selectionSet ) { |
| 1023 | $max = max( $max, $depth, $this->walk_depth_tree_only( $selection->selectionSet, $depth + 1 ) ); |
| 1024 | } |
| 1025 | } elseif ( $selection instanceof InlineFragmentNode ) { |
| 1026 | $max = max( $max, $this->walk_depth_tree_only( $selection->selectionSet, $depth ) ); |
| 1027 | } |
| 1028 | } |
| 1029 | |
| 1030 | return $max; |
| 1031 | } |
| 1032 | |
| 1033 | /** |
| 1034 | * Walk a selection set counting every field in the deepest chain, leaves |
| 1035 | * included. Produces the "shape" metric surfaced alongside the enforcement |
| 1036 | * metric in debug output. |
| 1037 | * |
| 1038 | * @param ?SelectionSetNode $selection_set The selection set to walk, or null for a leaf. |
| 1039 | * @param int $depth The depth of the selection set's parent. |
| 1040 | */ |
| 1041 | private function walk_depth_in_depth( ?SelectionSetNode $selection_set, int $depth ): int { |
| 1042 | if ( null === $selection_set ) { |
| 1043 | return $depth; |
| 1044 | } |
| 1045 | |
| 1046 | $max = $depth; |
| 1047 | foreach ( $selection_set->selections as $selection ) { |
| 1048 | if ( $selection instanceof FieldNode ) { |
| 1049 | $max = max( $max, $this->walk_depth_in_depth( $selection->selectionSet, $depth + 1 ) ); |
| 1050 | } elseif ( $selection instanceof InlineFragmentNode ) { |
| 1051 | $max = max( $max, $this->walk_depth_in_depth( $selection->selectionSet, $depth ) ); |
| 1052 | } |
| 1053 | } |
| 1054 | |
| 1055 | return $max; |
| 1056 | } |
| 1057 | |
| 1058 | /** |
| 1059 | * Check whether the parsed document contains a mutation operation. |
| 1060 | * |
| 1061 | * When an operation name is given, only that operation is checked; |
| 1062 | * otherwise any mutation definition in the document triggers a match. |
| 1063 | * |
| 1064 | * @param DocumentNode $document The parsed GraphQL document. |
| 1065 | * @param ?string $operation_name The requested operation name, if any. |
| 1066 | */ |
| 1067 | private function document_has_mutation( DocumentNode $document, ?string $operation_name ): bool { |
| 1068 | foreach ( $document->definitions as $definition ) { |
| 1069 | if ( ! $definition instanceof OperationDefinitionNode ) { |
| 1070 | continue; |
| 1071 | } |
| 1072 | |
| 1073 | if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) { |
| 1074 | continue; |
| 1075 | } |
| 1076 | |
| 1077 | if ( 'mutation' === $definition->operation ) { |
| 1078 | return true; |
| 1079 | } |
| 1080 | } |
| 1081 | |
| 1082 | return false; |
| 1083 | } |
| 1084 | } |
| 1085 |