PluginProbe
WPGraphQL / 2.22.0
WPGraphQL v2.22.0
2.22.3 2.22.2 2.22.1 2.22.0 2.21.1 2.21.0 2.20.0 2.19.0 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.1 2.14.0 2.13.0 2.2.0 2.3.0 2.3.3 2.3.6 2.3.8 2.5.0 2.5.1 2.5.2 2.5.3 All 177 releases
wp-graphql / src / Request.php

Request.php in WPGraphQL 2.22.0, at src/Request.php

1,208 lines 40.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPGraphQL;
4
5 use GraphQL\Error\DebugFlag;
6 use GraphQL\Error\Error;
7 use GraphQL\GraphQL;
8 use GraphQL\Server\OperationParams;
9 use GraphQL\Server\ServerConfig;
10 use GraphQL\Server\StandardServer;
11 use WPGraphQL\Server\ValidationRules\DisableIntrospection;
12 use WPGraphQL\Server\ValidationRules\QueryDepth;
13 use WPGraphQL\Server\ValidationRules\RequireAuthentication;
14 use WPGraphQL\Server\WPHelper;
15 use WPGraphQL\Utils\DebugLog;
16 use WPGraphQL\Utils\Preview;
17 use WPGraphQL\Utils\QueryAnalyzer;
18 use WPGraphQL\Utils\StructuredFields;
19
20 /**
21 * Class Request
22 *
23 * Proxies a request to graphql-php, applying filters and transforming request
24 * data as needed.
25 *
26 * @package WPGraphQL
27 *
28 * phpcs:disable -- PHPStan annotation.
29 * @phpstan-import-type RootValueResolver from \GraphQL\Server\ServerConfig
30 * @phpstan-import-type SerializableResult from \GraphQL\Executor\ExecutionResult
31 * phpcs:enable
32 */
33 class Request {
34
35 /**
36 * App context for this request.
37 *
38 * @var \WPGraphQL\AppContext
39 */
40 public $app_context;
41
42 /**
43 * Request data.
44 *
45 * @var array<string,mixed>|\GraphQL\Server\OperationParams
46 */
47 public $data;
48
49 /**
50 * Cached global post.
51 *
52 * @var ?\WP_Post
53 */
54 public $global_post;
55
56 /**
57 * Cached global wp_the_query.
58 *
59 * @var ?\WP_Query
60 */
61 private $global_wp_the_query;
62
63 /**
64 * GraphQL operation parameters for this request.
65 * Will be an array of OperationParams if this is a batch request.
66 *
67 * @var \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[]
68 */
69 public $params;
70
71 /**
72 * Schema for this request.
73 *
74 * @var \WPGraphQL\WPSchema
75 */
76 public $schema;
77
78 /**
79 * Debug log for WPGraphQL Requests
80 *
81 * @var \WPGraphQL\Utils\DebugLog
82 */
83 public $debug_log;
84
85 /**
86 * The Type Registry the Schema is built with
87 *
88 * @var \WPGraphQL\Registry\TypeRegistry
89 */
90 public $type_registry;
91
92 /**
93 * Validation rules for execution.
94 *
95 * @var array<string,\GraphQL\Validator\Rules\ValidationRule>
96 */
97 protected $validation_rules;
98
99 /**
100 * The default field resolver function. Default null
101 *
102 * @var callable|null
103 */
104 protected $field_resolver;
105
106 /**
107 * The root value of the request. Default null;
108 *
109 * @var mixed|RootValueResolver
110 */
111 protected $root_value;
112
113 /**
114 * @var \WPGraphQL\Utils\QueryAnalyzer
115 */
116 protected $query_analyzer;
117
118 /**
119 * Authentication error stored during before_execute().
120 * If set, the request should return this error instead of executing the query.
121 *
122 * @var \WP_Error|bool|null
123 */
124 protected $authentication_error = null;
125
126 /**
127 * Constructor
128 *
129 * @param array<string,mixed> $data The request data (for Non-HTTP requests).
130 *
131 * @return void
132 *
133 * @throws \Exception
134 */
135 public function __construct( array $data = [] ) {
136
137 /**
138 * Whether it's a GraphQL Request (http or internal)
139 *
140 * @since 0.0.5
141 */
142 if ( ! defined( 'GRAPHQL_REQUEST' ) ) {
143 define( 'GRAPHQL_REQUEST', true );
144 }
145
146 /**
147 * Filter "is_graphql_request" to return true
148 */
149 \WPGraphQL::set_is_graphql_request( true );
150
151 /**
152 * Action – intentionally with no context – to indicate a GraphQL Request has started.
153 * This is a great place for plugins to hook in and modify things that should only
154 * occur in the context of a GraphQL Request. The base class hooks into this action to
155 * kick off the schema creation, so types are not set up until this action has run!
156 */
157 /**
158 * Action – intentionally with no context – to indicate a GraphQL Request has started.
159 *
160 * @hookGroup request-lifecycle
161 * @since 0.0.32
162 */
163 do_action( 'init_graphql_request' );
164
165 // Start tracking debug log messages
166 $this->debug_log = new DebugLog();
167
168 // Set request data for passed-in (non-HTTP) requests.
169 $this->data = $data;
170
171 // Get the Type Registry
172 $this->type_registry = \WPGraphQL::get_type_registry();
173
174 // Get the App Context
175 $this->app_context = \WPGraphQL::get_app_context();
176
177 $this->validation_rules = $this->get_validation_rules();
178 $this->field_resolver = $this->get_field_resolver();
179
180 // Inject the type registry into the app context.
181 $this->app_context->type_registry = $this->type_registry;
182
183 // The query analyzer tracks nodes, models, list types and more
184 // to return in headers and debug messages to help developers understand
185 // what was resolved, how to cache it, etc.
186 $this->query_analyzer = new QueryAnalyzer( $this );
187 $this->query_analyzer->init();
188 }
189
190 /**
191 * Get the instance of the Query Analyzer
192 */
193 public function get_query_analyzer(): QueryAnalyzer {
194 return $this->query_analyzer;
195 }
196
197 /**
198 * @return callable|null
199 */
200 protected function get_field_resolver() {
201 return $this->field_resolver;
202 }
203
204 /**
205 * Return the validation rules to use in the request
206 *
207 * @return array<string,\GraphQL\Validator\Rules\ValidationRule>
208 */
209 protected function get_validation_rules(): array {
210 $validation_rules = GraphQL::getStandardValidationRules();
211
212 $validation_rules['require_authentication'] = new RequireAuthentication();
213 $validation_rules['disable_introspection'] = new DisableIntrospection();
214 $validation_rules['query_depth'] = new QueryDepth();
215
216 /**
217 * Return the validation rules to use in the request
218 *
219 * @param array<string,\GraphQL\Validator\Rules\ValidationRule> $validation_rules The validation rules to use in the request
220 * @param \WPGraphQL\Request $request The Request instance
221 * @hookGroup request-lifecycle
222 * @since 0.0.5
223 */
224 return apply_filters( 'graphql_validation_rules', $validation_rules, $this );
225 }
226
227 /**
228 * Returns the root value to use in the request.
229 *
230 * @return mixed|RootValueResolver|null
231 */
232 protected function get_root_value() {
233 /**
234 * Set the root value based on what was passed to the request
235 */
236 $root_value = is_array( $this->data ) && ! empty( $this->data['root_value'] ) ? $this->data['root_value'] : null;
237
238 /**
239 * Return the filtered root value
240 *
241 * @param mixed|RootValueResolver $root_value The root value the Schema should use to resolve with. Default null.
242 * @param \WPGraphQL\Request $request The Request instance
243 * @hookGroup request-lifecycle
244 * @since 0.0.5
245 */
246 return apply_filters( 'graphql_root_value', $root_value, $this );
247 }
248
249 /**
250 * Apply filters and do actions before GraphQL execution
251 *
252 * @throws \GraphQL\Error\Error
253 */
254 private function before_execute(): void {
255
256 /**
257 * Store the global post so that it can be reset after GraphQL execution
258 *
259 * This allows for a GraphQL query to be used in the middle of post content, such as in a Shortcode
260 * without disrupting the flow of the post as the global POST before and after GraphQL execution will be
261 * the same.
262 */
263 if ( ! empty( $GLOBALS['post'] ) ) {
264 $this->global_post = $GLOBALS['post'];
265 }
266
267 if ( ! empty( $GLOBALS['wp_query'] ) && $GLOBALS['wp_the_query'] instanceof \WP_Query ) {
268 $this->global_wp_the_query = clone $GLOBALS['wp_the_query'];
269 }
270
271 /**
272 * Reset authentication error state for this execution.
273 *
274 * This ensures each batch item starts with clean auth state, preventing
275 * errors from one batch item incorrectly persisting to subsequent items.
276 *
277 * @since 2.5.4
278 */
279 $this->authentication_error = null;
280
281 /**
282 * Check for authentication errors via the graphql_authentication_errors filter.
283 *
284 * Note: For HTTP requests, all CSRF protection and nonce validation is
285 * handled by Router::validate_http_request_authentication() before this
286 * code runs. This call allows plugins to hook in and indicate auth errors.
287 *
288 * @since 2.5.4 CSRF protection and nonce validation moved to Router.
289 */
290 $auth_error = $this->has_authentication_errors();
291
292 if ( false !== $auth_error ) {
293 // Store the authentication error for later use in execute methods
294 $this->authentication_error = $auth_error;
295 }
296
297 /**
298 * Update AppContext->viewer to reflect the current user after auth check.
299 *
300 * If the user was downgraded due to missing nonce (CSRF protection),
301 * the viewer should reflect the guest user, not the originally authenticated user.
302 *
303 * @since 2.6.0
304 */
305 $this->app_context->viewer = wp_get_current_user();
306
307 /**
308 * Set the preview context for the request, if provided (the `X-GraphQL-Preview`
309 * header, or the `preview` object in the request `extensions`). This carries the
310 * request-scoped preview params (the post being previewed, and the previewed
311 * featured image), mirroring the query params WordPress core uses for front-end
312 * previews.
313 */
314 $this->app_context->preview = $this->get_preview_context();
315
316 /**
317 * If a preview was requested for a post the current user is not allowed to preview,
318 * surface a debug-only notice (visible under GRAPHQL_DEBUG). The request still
319 * resolves the published data, so this never exposes unpublished content.
320 */
321 if ( is_array( $this->app_context->preview ) && ! Preview::viewer_can_preview( (int) $this->app_context->preview['databaseId'] ) ) {
322 graphql_debug(
323 __( 'Preview context was provided for a post the current user is not allowed to preview. The published data was resolved instead.', 'wp-graphql' ),
324 [ 'type' => 'PREVIEW_CONTEXT_IGNORED' ]
325 );
326 }
327
328 /**
329 * If the request is a batch request it will come back as an array
330 */
331 if ( is_array( $this->params ) ) {
332
333 // If the request is a batch request, but batch requests are disabled,
334 // bail early
335 if ( ! $this->is_batch_queries_enabled() ) {
336 throw new Error( esc_html__( 'Batch Queries are not supported', 'wp-graphql' ) );
337 }
338
339 $batch_limit = get_graphql_setting( 'batch_limit', 10 );
340 $batch_limit = absint( $batch_limit ) ? absint( $batch_limit ) : 10;
341
342 // If batch requests are enabled, but a limit is set and the request exceeds the limit
343 // fail now
344 if ( $batch_limit < count( $this->params ) ) {
345 // translators: First placeholder is the max number of batch operations allowed in a GraphQL request. The 2nd placeholder is the number of operations requested in the current request.
346 throw new Error( sprintf( esc_html__( 'Batch requests are limited to %1$d operations. This request contained %2$d', 'wp-graphql' ), absint( $batch_limit ), count( $this->params ) ) );
347 }
348
349 /**
350 * Execute batch queries
351 *
352 * @param \GraphQL\Server\OperationParams[] $params The operation params of the batch request
353 * @hookGroup request-lifecycle
354 * @since 0.0.5
355 */
356 do_action( 'graphql_execute_batch_queries', $this->params );
357
358 // Process the batched requests
359 array_walk( $this->params, [ $this, 'do_action' ] );
360 } else {
361 $this->do_action( $this->params );
362 }
363
364 // Get the Schema
365 $this->schema = \WPGraphQL::get_schema();
366
367 /**
368 * This action runs before execution of a GraphQL request (regardless if it's a single or batch request)
369 *
370 * @param \WPGraphQL\Request $request The instance of the Request being executed
371 * @hookGroup request-lifecycle
372 * @since 0.0.5
373 */
374 do_action( 'graphql_before_execute', $this );
375 }
376
377 /**
378 * Parses and normalizes the preview context for the request.
379 *
380 * The context mirrors the query params WordPress core uses for front-end previews
381 * (`preview_id`, `_thumbnail_id`, `preview_nonce`). Each transport uses its native
382 * structured form:
383 *
384 * - The `X-GraphQL-Preview` request header (an RFC 8941 Structured Field dictionary, with
385 * lowercase keys) is the primary source, because a headers UI exists in every GraphQL IDE:
386 *
387 * X-GraphQL-Preview: database_id=123, featured_image_database_id=456, nonce="abc"
388 *
389 * - The same context may instead be sent as a JSON `preview` object in the request
390 * `extensions` as a fallback (for example to keep it inside the operation body):
391 *
392 * "extensions": { "preview": { "databaseId": 123, "featuredImageDatabaseId": 456 } }
393 *
394 * The header takes precedence when both are present.
395 *
396 * The presence of a valid `databaseId` marks the request as a preview of that post.
397 * Authorization is enforced where the context is consumed (capability checks relative to
398 * the post), not here. The `nonce` is accepted but not verified, so clients can
399 * forward core's preview URL params wholesale; no verification is planned, since a
400 * nonce is session-bound and cannot authorize a different viewer. See the nonce
401 * contract in docs/previews.md, pinned by
402 * PreviewTest::testNonceIsAcceptedButNotVerifiedToday.
403 *
404 * @return array{databaseId:int,revisionDatabaseId:int,featuredImageDatabaseId:?int,nonce:?string}|null
405 */
406 private function get_preview_context(): ?array {
407 $preview = $this->get_preview_input();
408
409 if ( null === $preview ) {
410 return null;
411 }
412
413 $database_id = $this->get_positive_int_input( $preview['databaseId'] ?? null );
414
415 // Without a post id there is nothing to preview.
416 if ( empty( $database_id ) ) {
417 return null;
418 }
419
420 // Resolve the revision to overlay from, mirroring how WordPress core previews a
421 // post (`_set_preview()`): the post's newest autosave holds the in-progress,
422 // unsaved edits the "Preview" button shows. As in core, the newest autosave is
423 // used regardless of author, so a preview link shared with another user who can
424 // edit the post shows the same preview. Only look it up when the current user can
425 // preview the post, which avoids the query for unauthorized requests and is a
426 // defense-in-depth complement to the capability checks at the point of overlay.
427 $revision_database_id = 0;
428 if ( Preview::viewer_can_preview( $database_id ) ) {
429 $autosave = wp_get_post_autosave( $database_id );
430 $revision_database_id = $autosave instanceof \WP_Post ? (int) $autosave->ID : 0;
431 }
432
433 return [
434 'databaseId' => $database_id,
435 // The post's newest autosave (a revision) to overlay previewable fields from.
436 'revisionDatabaseId' => $revision_database_id,
437 // A `featuredImageDatabaseId` of 0 is meaningful (the featured image was removed
438 // in the preview), so only an absent or invalid value means "no override".
439 'featuredImageDatabaseId' => $this->get_non_negative_int_input( $preview['featuredImageDatabaseId'] ?? null ),
440 'nonce' => isset( $preview['nonce'] ) && is_string( $preview['nonce'] ) ? sanitize_text_field( $preview['nonce'] ) : null,
441 ];
442 }
443
444 /**
445 * Normalizes a client-supplied ID to a positive integer, rejecting invalid input
446 * rather than coercing it. `absint()` would silently flip a negative ID into a
447 * different, valid-looking positive ID, so a negative or malformed value must be
448 * treated as absent instead.
449 *
450 * @param mixed $value The raw client-supplied value.
451 *
452 * @return int The positive integer, or 0 when the value is absent or invalid.
453 */
454 private function get_positive_int_input( $value ): int {
455 if ( is_int( $value ) && $value > 0 ) {
456 return $value;
457 }
458
459 if ( is_string( $value ) && ctype_digit( $value ) && (int) $value > 0 ) {
460 return (int) $value;
461 }
462
463 return 0;
464 }
465
466 /**
467 * Like get_positive_int_input(), but 0 is a meaningful value (the previewed
468 * featured image was removed), so it is preserved rather than rejected.
469 *
470 * @param mixed $value The raw client-supplied value.
471 *
472 * @return ?int The non-negative integer, or null when the value is absent or invalid.
473 */
474 private function get_non_negative_int_input( $value ): ?int {
475 if ( is_int( $value ) && $value >= 0 ) {
476 return $value;
477 }
478
479 if ( is_string( $value ) && ctype_digit( $value ) ) {
480 return (int) $value;
481 }
482
483 return null;
484 }
485
486 /**
487 * Resolves the raw `preview` input for the request, keyed like the JSON `extensions.preview`
488 * object (`databaseId`, `featuredImageDatabaseId`, `nonce`).
489 *
490 * The `X-GraphQL-Preview` header (an RFC 8941 Structured Field dictionary) is the primary
491 * source, since a headers UI is available in every GraphQL IDE. When the header is absent,
492 * the same context is accepted as a JSON `preview` object in the request `extensions`.
493 *
494 * @return array<string,mixed>|null The raw (unnormalized) preview input, or null when none.
495 */
496 private function get_preview_input(): ?array {
497 // Primary: the `X-GraphQL-Preview` header (a structured-field dictionary).
498 if ( ! empty( $_SERVER['HTTP_X_GRAPHQL_PREVIEW'] ) ) {
499 $parsed = $this->parse_preview_header( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_GRAPHQL_PREVIEW'] ) ) );
500
501 if ( ! empty( $parsed ) ) {
502 return $parsed;
503 }
504 }
505
506 // Fallback: the JSON `preview` object in the request extensions.
507 if ( $this->params instanceof OperationParams ) {
508 $extensions = $this->params->extensions;
509
510 if ( is_array( $extensions ) && ! empty( $extensions['preview'] ) && is_array( $extensions['preview'] ) ) {
511 return $extensions['preview'];
512 }
513 }
514
515 // Batch requests: `extensions.preview` is per-operation, while the preview
516 // overlay is request-level, so it is not supported in a batch; only the header
517 // (which applies to every operation in the batch) carries preview context.
518 // Surface a debug notice rather than ignoring it silently.
519 if ( is_array( $this->params ) ) {
520 foreach ( $this->params as $operation ) {
521 if ( $operation instanceof OperationParams && is_array( $operation->extensions ) && ! empty( $operation->extensions['preview'] ) ) {
522 graphql_debug(
523 __( 'The `extensions.preview` object is not supported in batch requests and was ignored. Send the `X-GraphQL-Preview` header instead; it applies to every operation in the batch.', 'wp-graphql' ),
524 [ 'type' => 'PREVIEW_CONTEXT_IGNORED' ]
525 );
526 break;
527 }
528 }
529 }
530
531 return null;
532 }
533
534 /**
535 * Parses the `X-GraphQL-Preview` header as an RFC 8941 Structured Field dictionary.
536 *
537 * Example: `database_id=123, featured_image_database_id=456, nonce="abc"`.
538 *
539 * The full dictionary syntax is parsed (see Utils\StructuredFields), and the preview
540 * profile is applied on top: only the keys this feature defines are recognized, and
541 * their members must be Integers or Strings; members of any other type, and unknown
542 * keys, are ignored. The recognized keys are mapped to the same shape as the JSON
543 * `extensions.preview` object so both transports normalize identically.
544 *
545 * As RFC 8941 requires, a value that fails to parse is discarded in its entirety,
546 * in which case (as with an empty parse) the `extensions.preview` fallback applies.
547 * A discarded or key-less header surfaces a debug notice under GRAPHQL_DEBUG, so
548 * the silent fallback is still diagnosable.
549 *
550 * @param string $header The raw header value.
551 *
552 * @return array<string,mixed> The parsed preview input, keyed like `extensions.preview`.
553 */
554 private function parse_preview_header( string $header ): array {
555 // Map the header's lowercase structured-field keys to the JSON `preview` object keys.
556 $key_map = [
557 'database_id' => 'databaseId',
558 'featured_image_database_id' => 'featuredImageDatabaseId',
559 'nonce' => 'nonce',
560 ];
561
562 $dictionary = StructuredFields::parse_dictionary( $header );
563
564 // As RFC 8941 requires, a value that fails to parse is discarded in its
565 // entirety. Surface why under GRAPHQL_DEBUG rather than discarding silently.
566 if ( null === $dictionary ) {
567 graphql_debug(
568 __( 'The `X-GraphQL-Preview` header could not be parsed as an RFC 8941 dictionary and was discarded entirely, as the RFC requires. Common causes: a trailing comma, or camelCase keys (dictionary keys must be lowercase, e.g. `database_id`, not `databaseId`). The `extensions.preview` fallback applies if present.', 'wp-graphql' ),
569 [ 'type' => 'PREVIEW_CONTEXT_MALFORMED' ]
570 );
571 return [];
572 }
573
574 if ( empty( $dictionary ) ) {
575 return [];
576 }
577
578 $parsed = [];
579
580 foreach ( $dictionary as $key => $member ) {
581 if ( ! isset( $key_map[ $key ] ) ) {
582 continue;
583 }
584
585 // The preview profile accepts Integer and String members only.
586 if ( ! in_array( $member['type'], [ 'integer', 'string' ], true ) ) {
587 continue;
588 }
589
590 $parsed[ $key_map[ $key ] ] = $member['value'];
591 }
592
593 // A parseable header that carries none of the recognized keys is almost always
594 // a key-name mistake; say so instead of ignoring it silently.
595 if ( empty( $parsed ) ) {
596 graphql_debug(
597 __( 'The `X-GraphQL-Preview` header parsed as a valid dictionary but contained none of the recognized keys (`database_id`, `featured_image_database_id`, `nonce`) and was ignored. The `extensions.preview` fallback applies if present.', 'wp-graphql' ),
598 [ 'type' => 'PREVIEW_CONTEXT_UNRECOGNIZED' ]
599 );
600 }
601
602 return $parsed;
603 }
604
605 /**
606 * Checks authentication errors via the graphql_authentication_errors filter.
607 *
608 * As of 2.6.0, all CSRF protection and nonce validation for HTTP requests is
609 * handled by Router::validate_http_request_authentication() BEFORE any GraphQL
610 * hooks fire. This method now only provides:
611 * - Plugin integration via the graphql_authentication_errors filter
612 *
613 * False means no errors and execution continues.
614 * True or WP_Error prevents execution of the GraphQL request.
615 *
616 * @since 0.0.5
617 * @since 2.6.0 CSRF protection and nonce validation moved to Router.
618 *
619 * @return bool|\WP_Error False if no errors, true or WP_Error if there are errors.
620 *
621 * @see Router::validate_http_request_authentication()
622 */
623 protected function has_authentication_errors() {
624 return $this->filtered_authentication_errors( false );
625 }
626
627 /**
628 * Filter Authentication errors. Allows plugins that authenticate to hook in and prevent
629 * execution if Authentication errors exist.
630 *
631 * @param bool $authentication_errors Whether there are authentication errors with the request.
632 *
633 * @return bool
634 */
635 protected function filtered_authentication_errors( $authentication_errors = false ) {
636
637 /**
638 * If false, there are no authentication errors. If true, execution of the
639 * GraphQL request will be prevented and an error will be thrown.
640 *
641 * @param bool $authentication_errors Whether there are authentication errors with the request
642 * @param \WPGraphQL\Request $request Instance of the Request
643 * @hookGroup authentication
644 * @since 0.0.5
645 */
646 return apply_filters( 'graphql_authentication_errors', $authentication_errors, $this );
647 }
648
649 /**
650 * Performs actions and runs filters after execution completes
651 *
652 * @template T from (SerializableResult|SerializableResult[])|(\GraphQL\Executor\ExecutionResult|array<int,\GraphQL\Executor\ExecutionResult>)
653 *
654 * @param T $response The response from execution. Array for batch requests, single object for individual requests.
655 *
656 * @return T
657 */
658 private function after_execute( $response ) {
659
660 /**
661 * Authentication check has been moved to before_execute() as of 2.6.0.
662 * This ensures auth is validated BEFORE query execution, not after.
663 *
664 * @since 2.6.0 Auth check moved to before_execute()
665 * @see https://github.com/wp-graphql/wp-graphql/issues/3447
666 */
667
668 /**
669 * If the params and the $response are both arrays
670 * treat this as a batch request and map over the array to apply the
671 * after_execute_actions, otherwise apply them to the current response
672 */
673 if ( is_array( $this->params ) && is_array( $response ) ) {
674 $filtered_response = [];
675 foreach ( $response as $key => $resp ) {
676 $filtered_response[] = $this->after_execute_actions( $resp, (int) $key );
677 }
678 } else {
679 $filtered_response = $this->after_execute_actions( $response, null );
680 }
681
682 /**
683 * Reset the global post after execution
684 *
685 * This allows for a GraphQL query to be used in the middle of post content, such as in a Shortcode
686 * without disrupting the flow of the post as the global POST before and after GraphQL execution will be
687 * the same.
688 *
689 * We cannot use wp_reset_postdata here because it just resets the post from the global query which can
690 * be anything the because the resolvers themself can set it to whatever. So we just manually reset the
691 * post with setup_postdata we cached before this request.
692 */
693
694 if ( ! empty( $this->global_wp_the_query ) ) {
695 $GLOBALS['wp_the_query'] = $this->global_wp_the_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
696 wp_reset_query(); // phpcs:ignore WordPress.WP.DiscouragedFunctions.wp_reset_query_wp_reset_query
697 }
698
699 if ( ! empty( $this->global_post ) ) {
700 $GLOBALS['post'] = $this->global_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
701 setup_postdata( $this->global_post );
702 }
703
704 /**
705 * Run an action after GraphQL Execution
706 *
707 * @param mixed[] $filtered_response The response of the entire operation. Could be a single operation or a batch operation
708 * @param \WPGraphQL\Request $request Instance of the Request being executed
709 * @hookGroup request-lifecycle
710 * @since 0.0.5
711 */
712 do_action( 'graphql_after_execute', $filtered_response, $this );
713
714 /**
715 * Return the filtered response
716 */
717 return $filtered_response;
718 }
719
720 /**
721 * Apply filters and do actions after GraphQL execution
722 *
723 * @param mixed|array<string,mixed>|object $response The response for your GraphQL request
724 * @param int|null $key The array key of the params for batch requests
725 *
726 * @return mixed|array<string,mixed>|object
727 */
728 private function after_execute_actions( $response, $key = null ) {
729
730 /**
731 * Determine which params (batch or single request) to use when passing through to the actions
732 */
733 $query = null;
734 $operation = null;
735 $variables = null;
736 $query_id = null;
737
738 if ( $this->params instanceof OperationParams ) {
739 $operation = $this->params->operation;
740 $query = $this->params->query;
741 $query_id = $this->params->queryId;
742 $variables = $this->params->variables;
743 } elseif ( is_array( $this->params ) ) {
744 $operation = $this->params[ $key ]->operation ?? '';
745 $query = $this->params[ $key ]->query ?? '';
746 $query_id = $this->params[ $key ]->queryId ?? null;
747 $variables = $this->params[ $key ]->variables ?? null;
748 }
749
750 /**
751 * Run an action. This is a good place for debug tools to hook in to log things, etc.
752 *
753 * @param mixed|array<string,mixed>|object $response The response your GraphQL request
754 * @param \WPGraphQL\WPSchema $schema The schema object for the root request
755 * @param ?string $operation The name of the operation
756 * @param ?string $query The query that GraphQL executed
757 * @param ?array<string,mixed> $variables Variables to passed to your GraphQL query
758 * @param \WPGraphQL\Request $request Instance of the Request
759 *
760 * @hookGroup request-lifecycle
761 * @since 0.0.6
762 */
763 do_action( 'graphql_execute', $response, $this->schema, $operation, $query, $variables, $this );
764
765 /**
766 * Add the debug log to the request
767 */
768 if ( ! empty( $response ) ) {
769 $logs = $this->debug_log->get_logs();
770 if ( is_array( $response ) ) {
771 $response['extensions']['debug'] = $logs;
772 } else {
773 $response->extensions['debug'] = $logs;
774 }
775 }
776
777 /**
778 * Filter the $response of the GraphQL execution. This allows for the response to be filtered
779 * before it's returned, allowing granular control over the response at the latest point.
780 *
781 * POSSIBLE USAGE EXAMPLES:
782 * This could be used to ensure that certain fields never make it to the response if they match
783 * certain criteria, etc. For example, this filter could be used to check if a current user is
784 * allowed to see certain things, and if they are not, the $response could be filtered to remove
785 * the data they should not be allowed to see.
786 *
787 * Or, perhaps some systems want the response to always include some additional piece of data in
788 * every response, regardless of the request that was sent to it, this could allow for that
789 * to be hooked in and included in the $response.
790 *
791 * @param mixed|array<string,mixed>|object $response The response for your GraphQL query
792 * @param \WPGraphQL\WPSchema $schema The schema object for the root request
793 * @param ?string $operation The name of the operation
794 * @param ?string $query The query that GraphQL executed
795 * @param ?array<string,mixed> $variables Variables to passed to your GraphQL query
796 * @param \WPGraphQL\Request $request Instance of the Request
797 * @param ?string $query_id The query id that GraphQL executed
798 *
799 * @hookGroup request-lifecycle
800 * @since 0.0.5
801 */
802 $filtered_response = apply_filters( 'graphql_request_results', $response, $this->schema, $operation, $query, $variables, $this, $query_id );
803
804 /**
805 * Run an action after the response has been filtered, as the response is being returned.
806 * This is a good place for debug tools to hook in to log things, etc.
807 *
808 * @param mixed|array<string,mixed>|object $filtered_response The filtered response for the GraphQL request
809 * @param mixed|array<string,mixed>|object $response The response for your GraphQL request
810 * @param \WPGraphQL\WPSchema $schema The schema object for the root request
811 * @param ?string $operation The name of the operation
812 * @param ?string $query The query that GraphQL executed
813 * @param ?array<string,mixed> $variables Variables to passed to your GraphQL query
814 * @param \WPGraphQL\Request $request Instance of the Request
815 * @param ?string $query_id The query id that GraphQL executed
816 * @hookGroup request-lifecycle
817 * @since 0.0.5
818 */
819 do_action( 'graphql_return_response', $filtered_response, $response, $this->schema, $operation, $query, $variables, $this, $query_id );
820
821 /**
822 * Filter "is_graphql_request" back to false.
823 */
824 \WPGraphQL::set_is_graphql_request( false );
825
826 return $filtered_response;
827 }
828
829 /**
830 * Run action for a request.
831 *
832 * @param \GraphQL\Server\OperationParams $params OperationParams for the request.
833 */
834 private function do_action( OperationParams $params ): void {
835
836 /**
837 * Run an action for each request.
838 *
839 * @param ?string $query The GraphQL query
840 * @param ?string $operation The name of the operation
841 * @param ?array<string,mixed> $variables Variables to be passed to your GraphQL request
842 * @param \GraphQL\Server\OperationParams $params The Operation Params. This includes any extra params,
843 * such as extensions or any other modifications to the request body
844 * @hookGroup request-lifecycle
845 * @since 0.0.6
846 */
847 do_action( 'do_graphql_request', $params->query, $params->operation, $params->variables, $params );
848 }
849
850 /**
851 * Execute an internal request (graphql() function call).
852 *
853 * @return mixed[]
854 * @phpstan-return SerializableResult|SerializableResult[]|mixed[]
855 * @throws \Exception
856 */
857 public function execute() {
858 $helper = new WPHelper();
859
860 if ( ! $this->data instanceof OperationParams ) {
861 $this->params = $helper->parseRequestParams( 'POST', $this->data, [] );
862 } else {
863 $this->params = $this->data;
864 }
865
866 if ( is_array( $this->params ) ) {
867 return array_map(
868 function ( $data ) {
869 $this->data = $data;
870 return $this->execute();
871 },
872 $this->params
873 );
874 }
875
876 // If $this->params isn't an array or an OperationParams instance, then something probably went wrong.
877 if ( ! $this->params instanceof OperationParams ) {
878 throw new \Exception( 'Invalid request params.' );
879 }
880
881 /**
882 * Initialize the GraphQL Request
883 */
884 $this->before_execute();
885
886 /**
887 * If there was an authentication error, return it as a GraphQL error response
888 * instead of executing the query.
889 *
890 * IMPORTANT: This intentionally happens BEFORE the `pre_graphql_execute_request` filter.
891 * Authentication failures should fail fast for security reasons:
892 * - Don't give plugins a chance to interfere with or "undo" auth failures
893 * - Avoid unnecessary filter processing for failed requests
894 * - Ensure consistent, predictable auth error handling
895 *
896 * Plugins that need to observe ALL requests (including auth failures) should use
897 * earlier hooks like `graphql_before_execute` or `do_graphql_request`.
898 */
899 if ( null !== $this->authentication_error ) {
900 $error_message = is_wp_error( $this->authentication_error )
901 ? $this->authentication_error->get_error_message()
902 : __( 'Authentication Error', 'wp-graphql' );
903
904 return $this->after_execute(
905 [
906 'errors' => [
907 [
908 'message' => esc_html( $error_message ),
909 ],
910 ],
911 ]
912 );
913 }
914
915 /**
916 * Filter this to be anything other than null to short-circuit the request.
917 *
918 * @param ?SerializableResult $response The response to return early. Null continues execution.
919 * @param self $request The request instance being executed.
920 * @hookGroup request-lifecycle
921 * @since 1.6.6
922 */
923 $response = apply_filters( 'pre_graphql_execute_request', null, $this );
924
925 if ( null === $response ) {
926 /**
927 * @var \GraphQL\Server\OperationParams $params
928 */
929 $params = $this->params;
930
931 /**
932 * Allow the query string to be determined by a filter. Ex, when params->queryId is present, query can be retrieved.
933 *
934 * @param string $query The query string to execute.
935 * @param \GraphQL\Server\OperationParams $params Operation params for the request.
936 * @hookGroup request-lifecycle
937 * @since 0.0.5
938 */
939 $query = apply_filters(
940 'graphql_execute_query_params',
941 $params->query ?? '',
942 $params
943 );
944
945 $result = GraphQL::executeQuery(
946 $this->schema,
947 $query,
948 $this->get_root_value(),
949 $this->app_context,
950 $params->variables ?? null,
951 $params->operation ?? null,
952 $this->field_resolver,
953 $this->validation_rules
954 );
955
956 /**
957 * Return the result of the request
958 */
959 $response = $result->toArray( $this->get_debug_flag() );
960 }
961
962 /**
963 * Ensure the response is returned as a proper, populated array. Otherwise add an error.
964 */
965 if ( empty( $response ) || ! is_array( $response ) ) {
966 $response = [
967 'errors' => __( 'The GraphQL request returned an invalid response', 'wp-graphql' ),
968 ];
969 }
970
971 /**
972 * If the request is a batch request it will come back as an array
973 */
974 return $this->after_execute( $response );
975 }
976
977 /**
978 * Execute an HTTP request.
979 *
980 * @return SerializableResult|(\GraphQL\Executor\ExecutionResult|array<int,\GraphQL\Executor\ExecutionResult>)
981 * @throws \Exception
982 */
983 public function execute_http() {
984 if ( ! $this->is_valid_http_content_type() ) {
985 return $this->get_invalid_content_type_response();
986 }
987
988 /**
989 * Parse HTTP request.
990 */
991 $helper = new WPHelper();
992 $this->params = $helper->parseHttpRequest();
993
994 /**
995 * Initialize the GraphQL Request
996 */
997 $this->before_execute();
998
999 /**
1000 * If there was an authentication error, return it as a GraphQL error response
1001 * instead of executing the query. This ensures consistent error handling.
1002 */
1003 if ( null !== $this->authentication_error ) {
1004 $error_message = is_wp_error( $this->authentication_error )
1005 ? $this->authentication_error->get_error_message()
1006 : __( 'Authentication Error', 'wp-graphql' );
1007
1008 return $this->after_execute(
1009 [
1010 'errors' => [
1011 [
1012 'message' => esc_html( $error_message ),
1013 ],
1014 ],
1015 ]
1016 );
1017 }
1018
1019 /**
1020 * Get the response.
1021 */
1022 /**
1023 * Filter this to be anything other than null to short-circuit HTTP execution.
1024 *
1025 * @param mixed|null $response The response to return early. Null continues execution.
1026 * @param self $request The request instance being executed.
1027 * @hookGroup request-lifecycle
1028 * @since 1.6.6
1029 */
1030 $response = apply_filters( 'pre_graphql_execute_request', null, $this );
1031
1032 /**
1033 * If no cached response, execute the query
1034 */
1035 if ( null === $response ) {
1036 $server = $this->get_server();
1037 $response = $server->executeRequest( $this->params );
1038 }
1039
1040 return $this->after_execute( $response );
1041 }
1042
1043 /**
1044 * Validates the content type for HTTP POST requests
1045 */
1046 private function is_valid_http_content_type(): bool {
1047 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
1048 return true;
1049 }
1050
1051 $content_type = $this->get_content_type();
1052 if ( empty( $content_type ) ) {
1053 return false;
1054 }
1055
1056 $is_valid = 0 === stripos( $content_type, 'application/json' );
1057
1058 /**
1059 * Allow graphql to validate custom content types for HTTP POST requests
1060 *
1061 * @param bool $is_valid Whether the content type is valid
1062 * @param string $content_type The content type header value that was received
1063 *
1064 * @hookGroup request-lifecycle
1065 * @since 2.1.0
1066 */
1067 return (bool) apply_filters( 'graphql_is_valid_http_content_type', $is_valid, $content_type );
1068 }
1069
1070 /**
1071 * Gets the content type from the request headers
1072 */
1073 private function get_content_type(): string {
1074 if ( isset( $_SERVER['CONTENT_TYPE'] ) ) {
1075 return sanitize_text_field( $_SERVER['CONTENT_TYPE'] );
1076 }
1077
1078 if ( isset( $_SERVER['HTTP_CONTENT_TYPE'] ) ) {
1079 return sanitize_text_field( $_SERVER['HTTP_CONTENT_TYPE'] );
1080 }
1081
1082 return '';
1083 }
1084
1085 /**
1086 * Returns the error response for invalid content type
1087 *
1088 * @return array{errors: array{array{message: string}}}
1089 */
1090 private function get_invalid_content_type_response(): array {
1091 $content_type = $this->get_content_type();
1092
1093 /**
1094 * Filter the status code to return when the content type is invalid
1095 *
1096 * @param int $status_code The status code to return. Default 415.
1097 * @param string $content_type The content type header value that was received.
1098 * @hookGroup request-lifecycle
1099 * @since 2.1.0
1100 */
1101 $filtered_status_code = apply_filters( 'graphql_invalid_content_type_status_code', 415, $content_type );
1102
1103 // Set the status code to the filtered value if it's a valid status code.
1104 if ( is_numeric( $filtered_status_code ) ) {
1105 $filtered_status_code = (int) $filtered_status_code;
1106
1107 if ( $filtered_status_code > 100 && $filtered_status_code < 599 ) {
1108 Router::$http_status_code = $filtered_status_code;
1109 }
1110 }
1111
1112 return [
1113 'errors' => [
1114 [
1115 // translators: %s is the content type header value that was received
1116 'message' => sprintf( esc_html__( 'HTTP POST requests must have Content-Type: application/json header. Received: %s', 'wp-graphql' ), $content_type ),
1117 ],
1118 ],
1119 ];
1120 }
1121
1122 /**
1123 * Get the operation params for the request.
1124 *
1125 * @return \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[]
1126 */
1127 public function get_params() {
1128 return $this->params;
1129 }
1130
1131 /**
1132 * Returns the debug flag value
1133 *
1134 * @return int
1135 */
1136 public function get_debug_flag() {
1137 $flag = DebugFlag::INCLUDE_DEBUG_MESSAGE;
1138 if ( 0 !== get_current_user_id() ) {
1139 // Flag 2 shows the trace data, which should require user to be logged in to see by default
1140 $flag = DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
1141 }
1142
1143 return true === \WPGraphQL::debug() ? $flag : DebugFlag::NONE;
1144 }
1145
1146 /**
1147 * Determines if batch queries are enabled for the server.
1148 *
1149 * Default is to have batch queries enabled.
1150 */
1151 private function is_batch_queries_enabled(): bool {
1152 $batch_queries_enabled = true;
1153
1154 $batch_queries_setting = get_graphql_setting( 'batch_queries_enabled', 'on' );
1155 if ( 'off' === $batch_queries_setting ) {
1156 $batch_queries_enabled = false;
1157 }
1158
1159 /**
1160 * Filter whether batch queries are supported or not
1161 *
1162 * @param bool $batch_queries_enabled Whether Batch Queries should be enabled
1163 * @param \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[] $params Request operation params
1164 * @hookGroup request-lifecycle
1165 * @since 0.0.5
1166 */
1167 return (bool) apply_filters( 'graphql_is_batch_queries_enabled', $batch_queries_enabled, $this->params );
1168 }
1169
1170 /**
1171 * Create the GraphQL server that will process the request.
1172 */
1173 private function get_server(): StandardServer {
1174 $debug_flag = $this->get_debug_flag();
1175
1176 $config = new ServerConfig();
1177 $config
1178 ->setDebugFlag( $debug_flag )
1179 ->setSchema( $this->schema )
1180 ->setContext( $this->app_context )
1181 ->setValidationRules( $this->validation_rules )
1182 ->setQueryBatching( $this->is_batch_queries_enabled() );
1183
1184 if ( ! empty( $this->get_root_value() ) ) {
1185 $config->setRootValue( $this->get_root_value() );
1186 }
1187
1188 if ( ! empty( $this->field_resolver ) ) {
1189 $config->setFieldResolver( $this->field_resolver );
1190 }
1191
1192 /**
1193 * Run an action when the server config is created. The config can be acted
1194 * upon directly to override default values or implement new features, e.g.,
1195 * $config->setValidationRules().
1196 *
1197 * @param \GraphQL\Server\ServerConfig $config Server config
1198 * @param \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[] $params Request operation params
1199 *
1200 * @hookGroup request-lifecycle
1201 * @since 0.2.0
1202 */
1203 do_action( 'graphql_server_config', $config, $this->params );
1204
1205 return new StandardServer( $config );
1206 }
1207 }
1208