PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.1
WooCommerce v10.9.1
10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Api / Infrastructure / GraphQLControllerBase.php
woocommerce / src / Api / Infrastructure Last commit date
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