PluginProbe
WPGraphQL / trunk
WPGraphQL vtrunk
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 / Data / NodeResolver.php

NodeResolver.php in WPGraphQL trunk, at src/Data/NodeResolver.php

827 lines 28.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPGraphQL\Data;
4
5 use GraphQL\Deferred;
6 use GraphQL\Error\UserError;
7 use WPGraphQL\AppContext;
8 use WPGraphQL\Router;
9 use WPGraphQL\Utils\Utils;
10 use WP_Post;
11
12 class NodeResolver {
13
14 /**
15 * @var \WP
16 */
17 protected $wp;
18
19 /**
20 * @var \WPGraphQL\AppContext
21 */
22 protected $context;
23
24 /**
25 * @var string
26 */
27 protected $route;
28
29 /**
30 * NodeResolver constructor.
31 *
32 * @param \WPGraphQL\AppContext $context
33 *
34 * @return void
35 */
36 public function __construct( AppContext $context ) {
37 global $wp;
38 $this->wp = $wp;
39 $this->route = Router::$route . '/?$';
40 $this->wp->matched_rule = $this->route;
41 $this->context = $context;
42 }
43
44 /**
45 * Given a Post object, validates it before returning it.
46 *
47 * @param \WP_Post $post
48 *
49 * @return \WP_Post|null
50 */
51 public function validate_post( WP_Post $post ) {
52 if ( isset( $this->wp->query_vars['post_type'] ) && ( $post->post_type !== $this->wp->query_vars['post_type'] ) ) {
53 return null;
54 }
55
56 if ( ! $this->is_valid_node_type( 'ContentNode' ) ) {
57 return null;
58 }
59
60 if ( empty( $this->wp->query_vars['uri'] ) ) {
61 return $post;
62 }
63
64 // If the uri doesn't have the post's urlencoded name or ID in it, we must've found something we didn't expect
65 // so we will return null. Check decoded form, sanitize_title form, and raw post_name so both decoded and
66 // percent-encoded client input are accepted (issue #3582).
67 $uri = $this->wp->query_vars['uri'];
68 $name_in_uri = strpos( $uri, (string) $post->ID ) !== false
69 || strpos( $uri, urldecode( sanitize_title( $post->post_name ) ) ) !== false
70 || strpos( $uri, urldecode( $post->post_name ) ) !== false
71 || strpos( $uri, $post->post_name ) !== false;
72 if ( ! $name_in_uri ) {
73 return null;
74 }
75
76 return $post;
77 }
78
79 /**
80 * Given a Term object, validates it before returning it.
81 *
82 * @param \WP_Term $term
83 *
84 * @return \WP_Term|null
85 */
86 public function validate_term( \WP_Term $term ) {
87 if ( ! $this->is_valid_node_type( 'TermNode' ) ) {
88 return null;
89 }
90
91 if ( isset( $this->wp->query_vars['taxonomy'] ) && $term->taxonomy !== $this->wp->query_vars['taxonomy'] ) {
92 return null;
93 }
94
95 return $term;
96 }
97
98 /**
99 * Given the URI of a resource, this method attempts to resolve it and return the
100 * appropriate related object
101 *
102 * @param string $uri The path to be used as an identifier for the resource.
103 * @param array<string,mixed>|string $extra_query_vars Any extra query vars to consider
104 *
105 * @return mixed
106 * @throws \GraphQL\Error\UserError If the query class does not exist.
107 */
108 public function resolve_uri( string $uri, $extra_query_vars = '' ) {
109
110 /**
111 * When this filter return anything other than null, it will be used as a resolved node
112 * and the execution will be skipped.
113 *
114 * This is to be used in extensions to resolve their own nodes which might not use
115 * WordPress permalink structure.
116 *
117 * @param mixed|null $node The node, defaults to nothing.
118 * @param string $uri The uri being searched.
119 * @param \WPGraphQL\AppContext $content The app context.
120 * @param \WP $wp WP object.
121 * @param array<string,mixed>|string $extra_query_vars Any extra query vars to consider.
122 *
123 * @hookGroup request-lifecycle
124 * @since 0.0.5
125 */
126 $node = apply_filters( 'graphql_pre_resolve_uri', null, $uri, $this->context, $this->wp, $extra_query_vars );
127
128 if ( ! empty( $node ) ) {
129 return $node;
130 }
131
132 /**
133 * Comments are embedded as a #comment-{$id} in the post's content.
134 *
135 * If the URI is for a comment, we can resolve it now.
136 */
137 $comment_id = $this->maybe_parse_comment_uri( $uri );
138 if ( null !== $comment_id ) {
139 return $this->context->get_loader( 'comment' )->load_deferred( $comment_id );
140 }
141
142 /**
143 * Try to resolve the URI with WP_Query.
144 *
145 * This is the way WordPress native permalinks are resolved.
146 *
147 * @see \WP::main()
148 */
149
150 // Parse the URI and sets the $wp->query_vars property.
151 $uri = $this->parse_request( $uri, $extra_query_vars );
152
153 /**
154 * If the URI is the home page, we can resolve it now.
155 *
156 * The home page doesn't get a rewrite rule, so a bare '/' is resolved directly.
157 * We also detect the home page from the parsed request so the full home URL
158 * resolves when WordPress is installed in a subdirectory (e.g. `/blog/`), where
159 * parse_request() strips the home path and leaves an empty request (#3775).
160 */
161 if ( '/' === $uri || $this->is_home_request() ) {
162 return $this->resolve_home_page();
163 }
164
165 /**
166 * Filter the query class used to resolve the URI. By default this is WP_Query.
167 *
168 * This can be used by Extensions which use a different query class to resolve data.
169 *
170 * @param class-string $query_class The query class used to resolve the URI. Defaults to WP_Query.
171 * @param ?string $uri The uri being searched.
172 * @param \WPGraphQL\AppContext $content The app context.
173 * @param \WP $wp WP object.
174 * @param array<string,mixed>|string $extra_query_vars Any extra query vars to consider.
175 *
176 * @hookGroup request-lifecycle
177 * @since 0.0.5
178 */
179 $query_class = apply_filters( 'graphql_resolve_uri_query_class', 'WP_Query', $uri, $this->context, $this->wp, $extra_query_vars );
180
181 if ( ! class_exists( $query_class ) ) {
182 throw new UserError(
183 esc_html(
184 sprintf(
185 /* translators: %s: The query class used to resolve the URI */
186 __( 'The query class %s used to resolve the URI does not exist.', 'wp-graphql' ),
187 $query_class
188 )
189 )
190 );
191 }
192
193 $query_vars = $this->wp->query_vars;
194
195 /** @var \WP_Query $query */
196 $query = new $query_class( $query_vars );
197
198 // is the query is an archive
199 if ( isset( $query->posts[0] ) && $query->posts[0] instanceof WP_Post && ! $query->is_archive() ) {
200 $queried_object = $query->posts[0];
201 } else {
202 $queried_object = $query->get_queried_object();
203 }
204
205 // When no post was found but we have a slug, retry with alternate encoding so we can find
206 // posts whose post_name is stored in percent-encoded form (non-ASCII slugs). See issue #3582.
207 if ( ! $queried_object instanceof WP_Post
208 && isset( $query_vars['name'] )
209 && is_string( $query_vars['name'] )
210 && '' !== $query_vars['name'] ) {
211 $retry_name = strpos( $query_vars['name'], '%' ) !== false
212 ? urldecode( $query_vars['name'] )
213 : rawurlencode( $query_vars['name'] );
214 $retry_query_vars = $query_vars;
215 $retry_query_vars['name'] = $retry_name;
216 /** @var \WP_Query $retry_query */
217 $retry_query = new $query_class( $retry_query_vars );
218 $retry_queried_object = null;
219 if ( isset( $retry_query->posts[0] ) && $retry_query->posts[0] instanceof WP_Post && ! $retry_query->is_archive() ) {
220 $retry_queried_object = $retry_query->posts[0];
221 } else {
222 $retry_queried_object = $retry_query->get_queried_object();
223 }
224 if ( $retry_queried_object instanceof WP_Post ) {
225 $query = $retry_query;
226 $queried_object = $retry_queried_object;
227 }
228 }
229
230 /**
231 * When this filter return anything other than null, it will be used as a resolved node
232 * and the execution will be skipped.
233 *
234 * This is to be used in extensions to resolve their own nodes which might not use
235 * WordPress permalink structure.
236 *
237 * It differs from 'graphql_pre_resolve_uri' in that it has been called after the query has been run using the query vars.
238 *
239 * @param mixed|null $node The node, defaults to nothing.
240 * @param ?string $uri The uri being searched.
241 * @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|null $queried_object The queried object, if WP_Query returns one.
242 * @param \WP_Query $query The query object.
243 * @param \WPGraphQL\AppContext $content The app context.
244 * @param \WP $wp WP object.
245 * @param array<string,mixed>|string $extra_query_vars Any extra query vars to consider.
246 *
247 * @hookGroup request-lifecycle
248 * @since 0.0.5
249 */
250 $node = apply_filters( 'graphql_resolve_uri', null, $uri, $queried_object, $query, $this->context, $this->wp, $extra_query_vars );
251
252 if ( ! empty( $node ) ) {
253 return $node;
254 }
255
256 // Resolve Post Objects.
257 if ( $queried_object instanceof WP_Post ) {
258
259 // If Page for Posts is set, we need to return the Page archive, not the page.
260 if ( $query->is_posts_page ) {
261 // If were intentionally querying for a something other than a ContentType, we need to return null instead of the archive.
262 if ( ! $this->is_valid_node_type( 'ContentType' ) ) {
263 return null;
264 }
265
266 $post_type_object = get_post_type_object( 'post' );
267
268 if ( ! $post_type_object ) {
269 return null;
270 }
271
272 return ! empty( $post_type_object->name ) ? $this->context->get_loader( 'post_type' )->load_deferred( $post_type_object->name ) : null;
273 }
274
275 // Validate the post before returning it.
276 if ( ! $this->validate_post( $queried_object ) ) {
277 return null;
278 }
279
280 // A 404 from parse_request() means the requested path did not match a
281 // registered rewrite rule, so the queried object (if any) is an unrelated
282 // post that WP_Query returned because a pre-seeded `post_type` query var
283 // turned the request into an unbounded query. Bail in that case so typed
284 // `idType: URI` fields stay consistent with nodeByUri, which returns null
285 // for partial or wrong-hierarchy URIs (#3042).
286 //
287 // The exception is an explicit slug lookup (idType: SLUG), which passes a
288 // `name` and intentionally resolves by post_name without requiring the
289 // full path to match a rewrite rule.
290 if ( isset( $this->wp->query_vars['error'] ) && '404' === $this->wp->query_vars['error'] ) {
291 $is_slug_lookup = is_array( $extra_query_vars ) && ! empty( $extra_query_vars['name'] );
292 if ( ! $is_slug_lookup ) {
293 return null;
294 }
295 }
296
297 $post_id = $queried_object->ID;
298
299 $as_preview = false;
300
301 // if asPreview isn't passed explicitly as an argument on a node,
302 // attempt to fill the value from the $query_vars passed on the URI as a query param
303 if ( is_array( $extra_query_vars ) && array_key_exists( 'asPreview', $extra_query_vars ) && null === $extra_query_vars['asPreview'] && isset( $query_vars['preview'] ) ) {
304 // note, the "preview" arg comes through as a string, not a boolean so we need to check 'true' as a string
305 $as_preview = 'true' === $query_vars['preview'];
306 }
307
308 $as_preview = isset( $extra_query_vars['asPreview'] ) && true === $extra_query_vars['asPreview'] ? true : $as_preview;
309
310 if ( true === $as_preview ) {
311 $post_id = Utils::get_post_preview_id( $post_id );
312 }
313
314 return ! empty( $post_id ) ? $this->context->get_loader( 'post' )->load_deferred( $post_id ) : null;
315 }
316
317 // Resolve Terms.
318 if ( $queried_object instanceof \WP_Term ) {
319 // Validate the term before returning it.
320 if ( ! $this->validate_term( $queried_object ) ) {
321 return null;
322 }
323
324 return ! empty( $queried_object->term_id ) ? $this->context->get_loader( 'term' )->load_deferred( $queried_object->term_id ) : null;
325 }
326
327 // Resolve Post Types.
328 if ( $queried_object instanceof \WP_Post_Type ) {
329
330 // Bail if we're explicitly requesting a different GraphQL type.
331 if ( ! $this->is_valid_node_type( 'ContentType' ) ) {
332 return null;
333 }
334
335 return ! empty( $queried_object->name ) ? $this->context->get_loader( 'post_type' )->load_deferred( $queried_object->name ) : null;
336 }
337
338 // Resolve Users
339 if ( $queried_object instanceof \WP_User ) {
340 // Bail if we're explicitly requesting a different GraphQL type.
341 if ( ! $this->is_valid_node_type( 'User' ) ) {
342 return null;
343 }
344
345 return ! empty( $queried_object->ID ) ? $this->context->get_loader( 'user' )->load_deferred( $queried_object->ID ) : null;
346 }
347
348 /**
349 * This filter provides a fallback for resolving nodes that were unable to be resolved by NodeResolver::resolve_uri.
350 *
351 * This can be used by Extensions to resolve edge cases that are not handled by the core NodeResolver.
352 *
353 * @param mixed|null $node The node, defaults to nothing.
354 * @param ?string $uri The uri being searched.
355 * @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|null $queried_object The queried object, if WP_Query returns one.
356 * @param \WP_Query $query The query object.
357 * @param \WPGraphQL\AppContext $content The app context.
358 * @param \WP $wp WP object.
359 * @param array<string,mixed>|string $extra_query_vars Any extra query vars to consider.
360 *
361 * @hookGroup request-lifecycle
362 * @since 0.0.5
363 */
364 return apply_filters( 'graphql_post_resolve_uri', $node, $uri, $queried_object, $query, $this->context, $this->wp, $extra_query_vars );
365 }
366
367 /**
368 * Parses a URL to produce an array of query variables.
369 *
370 * Mimics WP::parse_request()
371 *
372 * @param string $uri
373 * @param array<string,mixed>|string $extra_query_vars
374 *
375 * @return string|null The parsed uri.
376 */
377 public function parse_request( string $uri, $extra_query_vars = '' ) {
378 // Attempt to parse the provided URI.
379 $parsed_url = wp_parse_url( $uri );
380
381 if ( false === $parsed_url ) {
382 graphql_debug(
383 __( 'Cannot parse provided URI', 'wp-graphql' ),
384 [
385 'uri' => $uri,
386 ]
387 );
388 return null;
389 }
390
391 // Bail if external URI.
392 if ( isset( $parsed_url['host'] ) ) {
393 $site_parts = wp_parse_url( site_url() );
394 $home_parts = wp_parse_url( home_url() );
395
396 $default_allowed_hosts = [];
397 if ( is_array( $site_parts ) && isset( $site_parts['host'] ) ) {
398 $default_allowed_hosts[] = $site_parts['host'];
399 }
400 if ( is_array( $home_parts ) && isset( $home_parts['host'] ) ) {
401 $default_allowed_hosts[] = $home_parts['host'];
402 }
403
404 /**
405 * Filters hostnames treated as belonging to this WordPress install when resolving node URIs.
406 *
407 * By default includes the hosts from `site_url()` and `home_url()`. Extensions may append
408 * hosts (for example language-specific domains mapped to the same site).
409 *
410 * @param string[] $allowed_hosts Hostnames permitted when comparing the parsed URI host.
411 *
412 * @hookGroup request-lifecycle
413 * @since 2.12.0
414 */
415 $allowed_hosts = apply_filters( 'graphql_allowed_hosts', $default_allowed_hosts );
416
417 if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) {
418 graphql_debug(
419 __( 'Cannot return a resource for an external URI', 'wp-graphql' ),
420 [
421 'uri' => $uri,
422 ]
423 );
424 return null;
425 }
426 }
427
428 if ( isset( $parsed_url['query'] ) && ( empty( $parsed_url['path'] ) || '/' === $parsed_url['path'] ) ) {
429 $uri = $parsed_url['query'];
430 } elseif ( isset( $parsed_url['path'] ) ) {
431 $uri = $parsed_url['path'];
432 }
433
434 /**
435 * Follows pattern from WP::parse_request()
436 *
437 * @see https://github.com/WordPress/wordpress-develop/blob/6.0.2/src/wp-includes/class-wp.php#L135
438 */
439 global $wp_rewrite;
440
441 $this->wp->query_vars = [];
442 $post_type_query_vars = [];
443
444 // Save explicit slug when resolving by slug (idType: SLUG) so we can restore it after rewrite parsing.
445 $saved_name = null;
446 if ( is_array( $extra_query_vars ) ) {
447 $this->wp->query_vars = &$extra_query_vars;
448 if ( isset( $extra_query_vars['name'] ) ) {
449 $saved_name = $extra_query_vars['name'];
450 }
451 } elseif ( ! empty( $extra_query_vars ) ) {
452 parse_str( $extra_query_vars, $this->wp->extra_query_vars );
453 }
454
455 // Set uri to Query vars.
456 $this->wp->query_vars['uri'] = $uri;
457
458 // Process PATH_INFO, REQUEST_URI, and 404 for permalinks.
459
460 // Fetch the rewrite rules.
461 $rewrite = $wp_rewrite->wp_rewrite_rules();
462 if ( ! empty( $rewrite ) ) {
463 // If we match a rewrite rule, this will be cleared.
464 $error = '404';
465 $this->wp->did_permalink = true;
466
467 $pathinfo = ! empty( $uri ) ? $uri : '';
468 list( $pathinfo ) = explode( '?', $pathinfo );
469 $pathinfo = str_replace( '%', '%25', $pathinfo );
470
471 list( $req_uri ) = explode( '?', $pathinfo );
472 $home_path = parse_url( home_url(), PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
473 $home_path_regex = '';
474 if ( is_string( $home_path ) && '' !== $home_path ) {
475 $home_path = trim( $home_path, '/' );
476 $home_path_regex = sprintf( '|^%s|i', preg_quote( $home_path, '|' ) );
477 }
478
479 /*
480 * Trim path info from the end and the leading home path from the front.
481 * For path info requests, this leaves us with the requesting filename, if any.
482 * For 404 requests, this leaves us with the requested permalink.
483 */
484 $query = '';
485 $matches = null;
486 $req_uri = str_replace( $pathinfo, '', $req_uri );
487 $req_uri = trim( $req_uri, '/' );
488 $pathinfo = trim( $pathinfo, '/' );
489
490 if ( ! empty( $home_path_regex ) ) {
491 $req_uri = preg_replace( $home_path_regex, '', $req_uri );
492 $req_uri = trim( $req_uri, '/' ); // @phpstan-ignore-line
493 $pathinfo = preg_replace( $home_path_regex, '', $pathinfo );
494 $pathinfo = trim( $pathinfo, '/' ); // @phpstan-ignore-line
495 }
496
497 // The requested permalink is in $pathinfo for path info requests and
498 // $req_uri for other requests.
499 if ( ! empty( $pathinfo ) && ! preg_match( '|^.*' . $wp_rewrite->index . '$|', $pathinfo ) ) {
500 $requested_path = $pathinfo;
501 } else {
502 // If the request uri is the index, blank it out so that we don't try to match it against a rule.
503 if ( $req_uri === $wp_rewrite->index ) {
504 $req_uri = '';
505 }
506 $requested_path = $req_uri;
507 }
508 $requested_file = $req_uri;
509
510 $this->wp->request = $requested_path;
511
512 // Look for matches.
513 $request_match = $requested_path;
514 if ( empty( $request_match ) ) {
515 // An empty request could only match against ^$ regex
516 if ( isset( $rewrite['$'] ) ) {
517 $this->wp->matched_rule = '$';
518 $query = $rewrite['$'];
519 $matches = [ '' ];
520 }
521 } else {
522 foreach ( (array) $rewrite as $match => $query ) {
523 // If the requested file is the anchor of the match, prepend it to the path info.
524 if ( ! empty( $requested_file ) && strpos( $match, $requested_file ) === 0 && $requested_file !== $requested_path ) {
525 $request_match = $requested_file . '/' . $requested_path;
526 }
527
528 if (
529 preg_match( "#^$match#", $request_match, $matches ) ||
530 preg_match( "#^$match#", urldecode( $request_match ), $matches )
531 ) {
532 if ( $wp_rewrite->use_verbose_page_rules && preg_match( '/pagename=\$matches\[([0-9]+)\]/', $query, $varmatch ) ) {
533 // This is a verbose page match, let's check to be sure about it.
534 $page = get_page_by_path( $matches[ $varmatch[1] ] ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_page_by_path_get_page_by_path
535 if ( ! $page ) {
536 continue;
537 }
538
539 $post_status_obj = get_post_status_object( $page->post_status );
540 if (
541 ( ! isset( $post_status_obj->public ) || ! $post_status_obj->public ) &&
542 ( ! isset( $post_status_obj->protected ) || ! $post_status_obj->protected ) &&
543 ( ! isset( $post_status_obj->private ) || ! $post_status_obj->private ) &&
544 ( ! isset( $post_status_obj->exclude_from_search ) || $post_status_obj->exclude_from_search )
545 ) {
546 continue;
547 }
548 }
549
550 // Got a match.
551 $this->wp->matched_rule = $match;
552 break;
553 }
554 }
555 }
556
557 if ( ! empty( $this->wp->matched_rule ) && $this->wp->matched_rule !== $this->route ) {
558 // Trim the query of everything up to the '?'.
559 $query = preg_replace( '!^.+\?!', '', $query );
560
561 // Substitute the substring matches into the query.
562 $query = addslashes( \WP_MatchesMapRegex::apply( $query, $matches ) ); // @phpstan-ignore-line
563
564 $this->wp->matched_query = $query;
565
566 // Parse the query.
567 parse_str( $query, $perma_query_vars );
568
569 // If we're processing a 404 request, clear the error var since we found something.
570 // @phpstan-ignore-next-line
571 if ( '404' == $error ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
572 unset( $error );
573 }
574 }
575 }
576
577 /**
578 * Filters the query variables allowed before processing.
579 *
580 * Allows (publicly allowed) query vars to be added, removed, or changed prior
581 * to executing the query. Needed to allow custom rewrite rules using your own arguments
582 * to work, or any other custom query variables you want to be publicly available.
583 *
584 * @since 1.5.0
585 *
586 * @param string[] $public_query_vars The array of allowed query variable names.
587 */
588 $this->wp->public_query_vars = apply_filters( 'query_vars', $this->wp->public_query_vars );
589
590 foreach ( get_post_types( [ 'show_in_graphql' => true ], 'objects' ) as $post_type => $t ) {
591 /** @var \WP_Post_Type $t */
592 if ( $t->query_var ) {
593 $post_type_query_vars[ $t->query_var ] = $post_type;
594 }
595 }
596
597 foreach ( $this->wp->public_query_vars as $wpvar ) {
598 $parsed_query = [];
599 if ( isset( $parsed_url['query'] ) ) {
600 parse_str( $parsed_url['query'], $parsed_query );
601 }
602
603 if ( isset( $this->wp->extra_query_vars[ $wpvar ] ) ) {
604 $this->wp->query_vars[ $wpvar ] = $this->wp->extra_query_vars[ $wpvar ];
605 } elseif ( isset( $_GET[ $wpvar ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
606 $this->wp->query_vars[ $wpvar ] = $_GET[ $wpvar ]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended
607 } elseif ( isset( $perma_query_vars[ $wpvar ] ) ) {
608 $this->wp->query_vars[ $wpvar ] = $perma_query_vars[ $wpvar ];
609 } elseif ( isset( $parsed_query[ $wpvar ] ) ) {
610 $this->wp->query_vars[ $wpvar ] = $parsed_query[ $wpvar ];
611 }
612
613 if ( ! empty( $this->wp->query_vars[ $wpvar ] ) ) {
614 if ( ! is_array( $this->wp->query_vars[ $wpvar ] ) ) {
615 $this->wp->query_vars[ $wpvar ] = (string) $this->wp->query_vars[ $wpvar ];
616 } else {
617 foreach ( $this->wp->query_vars[ $wpvar ] as $vkey => $v ) {
618 if ( is_scalar( $v ) ) {
619 $this->wp->query_vars[ $wpvar ][ $vkey ] = (string) $v;
620 }
621 }
622 }
623
624 if ( isset( $post_type_query_vars[ $wpvar ] ) ) {
625 $this->wp->query_vars['post_type'] = $post_type_query_vars[ $wpvar ];
626 $this->wp->query_vars['name'] = $this->wp->query_vars[ $wpvar ];
627 }
628 }
629 }
630
631 // Restore explicit slug when resolving by slug (e.g. idType: SLUG), so percent-encoded slugs
632 // from the client are not overwritten by decoded values from rewrite rules (issue #3582).
633 if ( null !== $saved_name ) {
634 $this->wp->query_vars['name'] = $saved_name;
635 }
636
637 // Convert urldecoded spaces back into '+'.
638 foreach ( get_taxonomies( [ 'show_in_graphql' => true ], 'objects' ) as $t ) {
639 if ( $t->query_var && isset( $this->wp->query_vars[ $t->query_var ] ) ) {
640 $this->wp->query_vars[ $t->query_var ] = str_replace( ' ', '+', $this->wp->query_vars[ $t->query_var ] );
641 }
642 }
643
644 // Limit publicly queried post_types to those that are publicly_queryable
645 if ( isset( $this->wp->query_vars['post_type'] ) ) {
646 $queryable_post_types = get_post_types( [ 'show_in_graphql' => true ] );
647 if ( ! is_array( $this->wp->query_vars['post_type'] ) ) {
648 if ( ! in_array( $this->wp->query_vars['post_type'], $queryable_post_types, true ) ) {
649 unset( $this->wp->query_vars['post_type'] );
650 }
651 } else {
652 $this->wp->query_vars['post_type'] = array_intersect( $this->wp->query_vars['post_type'], $queryable_post_types );
653 }
654 }
655
656 // Resolve conflicts between posts with numeric slugs and date archive queries.
657 $this->wp->query_vars = wp_resolve_numeric_slug_conflicts( $this->wp->query_vars );
658
659 foreach ( (array) $this->wp->private_query_vars as $var ) {
660 if ( isset( $this->wp->extra_query_vars[ $var ] ) ) {
661 $this->wp->query_vars[ $var ] = $this->wp->extra_query_vars[ $var ];
662 }
663 }
664
665 if ( isset( $error ) ) {
666 $this->wp->query_vars['error'] = $error;
667 }
668
669 // if the parsed url is ONLY a query, unset the pagename query var
670 if ( isset( $this->wp->query_vars['pagename'], $parsed_url['query'] ) && ( $parsed_url['query'] === $this->wp->query_vars['pagename'] ) ) {
671 unset( $this->wp->query_vars['pagename'] );
672 }
673
674 /**
675 * Filters the array of parsed query variables.
676 *
677 * @param array<string,mixed> $query_vars The array of requested query variables.
678 *
679 * @since 2.1.0
680 */
681 $this->wp->query_vars = apply_filters( 'request', $this->wp->query_vars );
682
683 // We don't need the GraphQL args anymore.
684 unset( $this->wp->query_vars['graphql'] );
685
686 // CRITICAL FIX: Prevent REST API from processing requests during GraphQL execution
687 //
688 // If we're processing a GraphQL request and WordPress has identified this URI as a
689 // REST API route (rest_route is set in query_vars), we must prevent REST API from
690 // processing it. REST API hooks into parse_request and will output JSON and exit,
691 // breaking the GraphQL response.
692 //
693 // IMPORTANT: This fix is critical and was confirmed in production. Removing this
694 // code will cause REST API JSON responses to be returned instead of GraphQL responses
695 // when nodeByUri queries use REST API endpoint URIs.
696 //
697 // We use is_graphql_request() instead of Router::get_request() to ensure the fix
698 // applies to all GraphQL requests, including internal calls via graphql() function,
699 // not just HTTP-routed requests.
700 //
701 // Regression test: testRestRouteIsRemovedFromQueryVarsDuringGraphQLRequest()
702 // See: https://github.com/wp-graphql/wp-graphql/issues/3513
703 if ( is_graphql_request() && isset( $this->wp->query_vars['rest_route'] ) ) {
704 unset( $this->wp->query_vars['rest_route'] );
705 }
706
707 do_action_ref_array( 'parse_request', [ &$this->wp ] );
708
709 return $uri;
710 }
711
712 /**
713 * Checks if the node type is set in the query vars and, if so, whether it matches the node type.
714 *
715 * @param string $node_type The node type to check.
716 */
717 protected function is_valid_node_type( string $node_type ): bool {
718 return ! isset( $this->wp->query_vars['nodeType'] ) || $this->wp->query_vars['nodeType'] === $node_type;
719 }
720
721 /**
722 * Determines whether the parsed request is for the site's home page.
723 *
724 * After parse_request() strips the home path, a request for the home page has an
725 * empty `$wp->request`. This is how the home URL is recognized when WordPress is
726 * installed in a subdirectory and the full home URL (e.g. `/blog/`) is requested,
727 * rather than relying on a literal '/' uri (#3775).
728 *
729 * We require that permalink parsing actually ran (`did_permalink`) so plain
730 * permalink installs keep relying on the literal '/' check, and we bail if any
731 * query var that identifies a specific node or archive is present.
732 */
733 protected function is_home_request(): bool {
734 // Only infer the home page from the parsed request when permalink parsing ran.
735 if ( empty( $this->wp->did_permalink ) ) {
736 return false;
737 }
738
739 // A home request has no remaining path after the home path is stripped.
740 if ( ! empty( $this->wp->request ) ) {
741 return false;
742 }
743
744 // Bail if a query var that identifies specific content or an archive is set
745 // (for example a query-string request like `/subdir/?p=5` or `/subdir/?cat=2`).
746 $content_query_vars = [
747 'p',
748 'page_id',
749 'name',
750 'pagename',
751 'attachment',
752 'attachment_id',
753 'cat',
754 'category_name',
755 'tag',
756 'tag_id',
757 'author',
758 'author_name',
759 'year',
760 'monthnum',
761 'day',
762 'hour',
763 'minute',
764 'second',
765 'm',
766 'w',
767 's',
768 'feed',
769 ];
770
771 foreach ( $content_query_vars as $var ) {
772 if ( ! empty( $this->wp->query_vars[ $var ] ) ) {
773 return false;
774 }
775 }
776
777 return true;
778 }
779
780 /**
781 * Resolves the home page.
782 *
783 * If the homepage is a static page, return the page, otherwise we return the Posts `ContentType`.
784 *
785 * @todo Replace `ContentType` with an `Archive` type.
786 */
787 protected function resolve_home_page(): ?Deferred {
788 $page_id = get_option( 'page_on_front', 0 );
789 $show_on_front = get_option( 'show_on_front', 'posts' );
790
791 // If the homepage is a static page, return the page.
792 if ( 'page' === $show_on_front && ! empty( $page_id ) ) {
793 $page = get_post( $page_id );
794
795 if ( empty( $page ) ) {
796 return null;
797 }
798
799 return $this->context->get_loader( 'post' )->load_deferred( $page->ID );
800 }
801
802 // If the homepage is set to latest posts, we need to make sure not to resolve it when when for other types.
803 if ( ! $this->is_valid_node_type( 'ContentType' ) ) {
804 return null;
805 }
806
807 // We dont have an 'Archive' type, so we resolve to the ContentType.
808 return $this->context->get_loader( 'post_type' )->load_deferred( 'post' );
809 }
810
811 /**
812 * Checks if the URI is a comment URI and, if so, returns the comment ID.
813 *
814 * @param string $uri The URI to check.
815 */
816 protected function maybe_parse_comment_uri( string $uri ): ?int {
817 $comment_match = [];
818 // look for a #comment-{$id} anywhere in the uri.
819 if ( preg_match( '/#comment-(\d+)/', $uri, $comment_match ) ) {
820 $comment_id = absint( $comment_match[1] );
821 return ! empty( $comment_id ) ? $comment_id : null;
822 }
823
824 return null;
825 }
826 }
827