From 27f28fe4f9be985603b9c2952225ba16f88d7779 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 20 Apr 2026 16:03:43 +0200 Subject: [PATCH 1/6] Add "resolvable" routes for the REST API --- src/wp-includes/rest-api.php | 93 +++++++----- .../class-wp-rest-resolvable-route.php | 143 ++++++++++++++++++ .../rest-api/class-wp-rest-server.php | 139 +++++++++++------ src/wp-settings.php | 1 + tests/phpunit/tests/rest-api.php | 75 +++++++++ 5 files changed, 368 insertions(+), 83 deletions(-) create mode 100644 src/wp-includes/rest-api/class-wp-rest-resolvable-route.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c524f9e22a12f..4d0aa850e6e0b 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -92,67 +92,86 @@ function register_rest_route( $route_namespace, $route, $args = array(), $overri ); } - if ( isset( $args['args'] ) ) { + if ( ! is_callable( $args ) && isset( $args['args'] ) ) { $common_args = $args['args']; unset( $args['args'] ); } else { $common_args = array(); } - if ( isset( $args['callback'] ) ) { + if ( is_callable( $args ) || isset( $args['callback'] ) ) { // Upgrade a single set to multiple. $args = array( $args ); } - $defaults = array( - 'methods' => 'GET', - 'callback' => null, - 'args' => array(), - ); - foreach ( $args as $key => &$arg_group ) { if ( ! is_numeric( $key ) ) { // Route option, skip here. continue; } - $arg_group = array_merge( $defaults, $arg_group ); - $arg_group['args'] = array_merge( $common_args, $arg_group['args'] ); + if ( is_callable( $arg_group ) ) { + // Just-in-time resolvable callback, we'll normalize it later. + $arg_group = new WP_REST_Resolvable_Route( $clean_namespace, $route, $arg_group ); + continue; + } + + $arg_group = normalize_rest_endpoint_options( $clean_namespace, $route, $arg_group, $common_args ); + } + + $full_route = '/' . $clean_namespace . '/' . trim( $route, '/' ); + rest_get_server()->register_route( $clean_namespace, $full_route, $args, $override ); + return true; +} - if ( ! isset( $arg_group['permission_callback'] ) ) { +/** + * Normalize the options for a single REST API endpoint. + * + * @param string $namespace The route namespace. + * @param string $route The route. + * @param array $endpoint The endpoint options. + * @param array $common_args Common arguments to merge with endpoint-specific arguments. + */ +function normalize_rest_endpoint_options( string $namespace, string $route, array $endpoint, array $common_args = [] ) { + $defaults = array( + 'methods' => 'GET', + 'callback' => null, + 'args' => array(), + ); + $endpoint = array_merge( $defaults, $endpoint ); + $endpoint['args'] = array_merge( $common_args, $endpoint['args'] ); + + if ( ! isset( $endpoint['permission_callback'] ) ) { + _doing_it_wrong( + 'register_rest_route', + sprintf( + /* translators: 1: The REST API route being registered, 2: The argument name, 3: The suggested function name. */ + __( 'The REST API route definition for %1$s is missing the required %2$s argument. For REST API routes that are intended to be public, use %3$s as the permission callback.' ), + '' . $namespace . '/' . trim( $route, '/' ) . '', + 'permission_callback', + '__return_true' + ), + '5.5.0' + ); + } + + foreach ( $endpoint['args'] as $arg ) { + if ( ! is_array( $arg ) ) { _doing_it_wrong( - __FUNCTION__, + 'register_rest_route', sprintf( - /* translators: 1: The REST API route being registered, 2: The argument name, 3: The suggested function name. */ - __( 'The REST API route definition for %1$s is missing the required %2$s argument. For REST API routes that are intended to be public, use %3$s as the permission callback.' ), - '' . $clean_namespace . '/' . trim( $route, '/' ) . '', - 'permission_callback', - '__return_true' + /* translators: 1: $args, 2: The REST API route being registered. */ + __( 'REST API %1$s should be an array of arrays. Non-array value detected for %2$s.' ), + '$args', + '' . $namespace . '/' . trim( $route, '/' ) . '' ), - '5.5.0' + '6.1.0' ); - } - - foreach ( $arg_group['args'] as $arg ) { - if ( ! is_array( $arg ) ) { - _doing_it_wrong( - __FUNCTION__, - sprintf( - /* translators: 1: $args, 2: The REST API route being registered. */ - __( 'REST API %1$s should be an array of arrays. Non-array value detected for %2$s.' ), - '$args', - '' . $clean_namespace . '/' . trim( $route, '/' ) . '' - ), - '6.1.0' - ); - break; // Leave the foreach loop once a non-array argument was found. - } + break; // Leave the foreach loop once a non-array argument was found. } } - $full_route = '/' . $clean_namespace . '/' . trim( $route, '/' ); - rest_get_server()->register_route( $clean_namespace, $full_route, $args, $override ); - return true; + return $endpoint; } /** diff --git a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php new file mode 100644 index 0000000000000..a5def3c46ef4c --- /dev/null +++ b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php @@ -0,0 +1,143 @@ +namespace = $namespace; + $this->route = $route; + $this->callable = $closure; + } + + /** + * Invokes the callable to resolve, if needed. + * + * Routes can only be resolved once, the first time they're used. Any + * subsequent calls will return the same resolved definition, which may + * be modified by reference if needed. + * + * @since X.X.0 + * + * @return array The resolved route definition. + */ + public function __invoke() { + if ( ! $this->resolved ) { + $this->resolved = call_user_func( $this->callable ); + + // Normalize the result. + $this->resolved = normalize_rest_endpoint_options( $this->namespace, $this->route, $this->resolved ); + } + return $this->resolved; + } + + /** + * Checks a single array key exists in the resolved route definition. + * + * @since X.X.0 + * + * @param string $key The key to check. + * @return bool True if the key exists, false otherwise. + */ + public function offsetExists( mixed $k ) : bool { + $this->__invoke(); + return isset( $this->resolved[ $k ] ); + } + + /** + * Gets a single array key from the resolved route definition. + * + * @since X.X.0 + * + * @param string $key The key to retrieve. + * @return mixed The value of the key, or null if not set. Returns by reference, so it can be modified if needed. + */ + public function &offsetGet( mixed $k ) : mixed { + $this->__invoke(); + return $this->resolved[ $k ]; + } + + /** + * Sets a single array key in the resolved route definition. + * + * @since X.X.0 + * + * @param string $key The key to set. + * @param mixed $value The value to set. + */ + public function offsetSet( mixed $k, mixed $v ) : void { + $this->__invoke(); + $this->resolved[ $k ] = $v; + } + + /** + * Unsets a single array key in the resolved route definition. + * + * @since X.X.0 + * + * @param string $key The key to unset. + */ + public function offsetUnset( mixed $k ) : void { + $this->__invoke(); + unset( $this->resolved[ $k ] ); + } + + /** + * Gets an iterator for the resolved route definition. + * + * @since X.X.0 + * + * @return Traversable An iterator for the resolved route definition. + */ + public function getIterator(): Traversable { + $this->__invoke(); + return new ArrayIterator( $this->resolved ); + } + + /** + * Counts the number of elements in the resolved route definition. + * + * @since X.X.0 + * + * @return int The number of elements in the resolved route definition. + */ + public function count() : int { + $this->__invoke(); + return count( $this->resolved ); + } +} diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index dbf605523d2dc..e61362ded3eda 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -927,28 +927,23 @@ public function register_route( $route_namespace, $route, $route_args, $override } /** - * Retrieves the route map. + * Get unresolved route configuration. * - * The route map is an associative array with path regexes as the keys. The - * value is an indexed array with the callback function/method as the first - * item, and a bitmask of HTTP methods as the second item (see the class - * constants). + * For performance reasons, routes can specify options as a callback instead + * of a direct array. This route specification remains "unresolved" until + * we need to read from the array, at which point we do just-in-time + * normalization of the options. * - * Each route can be mapped to more than one callback by using an array of - * the indexed arrays. This allows mapping e.g. GET requests to one callback - * and POST requests to another. + * When you don't need the full options (i.e. for routing), using the + * unresolved routes has higher performance. * - * Note that the path regexes (array keys) must have @ escaped, as this is - * used as the delimiter with preg_match() - * - * @since 4.4.0 - * @since 5.4.0 Added `$route_namespace` parameter. + * @since X.X.0 * * @param string $route_namespace Optionally, only return routes in the given namespace. * @return array `'/path/regex' => array( $callback, $bitmask )` or * `'/path/regex' => array( array( $callback, $bitmask ), ...)`. */ - public function get_routes( $route_namespace = '' ) { + public function get_unresolved_routes( $route_namespace = '' ) { $endpoints = $this->endpoints; if ( $route_namespace ) { @@ -967,6 +962,25 @@ public function get_routes( $route_namespace = '' ) { */ $endpoints = apply_filters( 'rest_endpoints', $endpoints ); + return $endpoints; + } + + /** + * Resolve a route's handlers and options. + * + * When using unresolved routes from WP_REST_Server::get_unresolved_routes(), + * the route handlers and options are not normalized until this method is + * called. This allows for just-in-time normalization of routes, which can + * improve performance when only routing is needed. + * + * @since X.X.0 + * + * @param string $route The route to normalize. + * @param array $handlers Route option handlers to normalize. + * @return array `'/path/regex' => array( $callback, $bitmask )` or + * `'/path/regex' => array( array( $callback, $bitmask ), ...)`. + */ + public function resolve_route_handlers( string $route, array &$handlers ) { // Normalize the endpoints. $defaults = array( 'methods' => '', @@ -976,44 +990,76 @@ public function get_routes( $route_namespace = '' ) { 'args' => array(), ); - foreach ( $endpoints as $route => &$handlers ) { + if ( isset( $handlers['callback'] ) ) { + // Single endpoint, add one deeper. + $handlers = array( $handlers ); + } - if ( isset( $handlers['callback'] ) ) { - // Single endpoint, add one deeper. - $handlers = array( $handlers ); - } + if ( ! isset( $this->route_options[ $route ] ) ) { + $this->route_options[ $route ] = array(); + } - if ( ! isset( $this->route_options[ $route ] ) ) { - $this->route_options[ $route ] = array(); + foreach ( $handlers as $key => &$handler ) { + if ( ! is_numeric( $key ) ) { + // Route option, move it to the options. + $this->route_options[ $route ][ $key ] = $handler; + unset( $handlers[ $key ] ); + continue; } - foreach ( $handlers as $key => &$handler ) { + // Resolve any just-in-time resolvable options, and apply defaults. + $handler = iterator_to_array( $handler ); + $handler = wp_parse_args( $handler, $defaults ); - if ( ! is_numeric( $key ) ) { - // Route option, move it to the options. - $this->route_options[ $route ][ $key ] = $handler; - unset( $handlers[ $key ] ); - continue; - } + // Allow comma-separated HTTP methods. + if ( is_string( $handler['methods'] ) ) { + $methods = explode( ',', $handler['methods'] ); + } elseif ( is_array( $handler['methods'] ) ) { + $methods = $handler['methods']; + } else { + $methods = array(); + } - $handler = wp_parse_args( $handler, $defaults ); + $handler['methods'] = array(); - // Allow comma-separated HTTP methods. - if ( is_string( $handler['methods'] ) ) { - $methods = explode( ',', $handler['methods'] ); - } elseif ( is_array( $handler['methods'] ) ) { - $methods = $handler['methods']; - } else { - $methods = array(); - } + foreach ( $methods as $method ) { + $method = strtoupper( trim( $method ) ); + $handler['methods'][ $method ] = true; + } + } + return $handlers; + } - $handler['methods'] = array(); + /** + * Retrieves the route map. + * + * The route map is an associative array with path regexes as the keys. The + * value is an indexed array with the callback function/method as the first + * item, and a bitmask of HTTP methods as the second item (see the class + * constants). + * + * Each route can be mapped to more than one callback by using an array of + * the indexed arrays. This allows mapping e.g. GET requests to one callback + * and POST requests to another. + * + * Note that the path regexes (array keys) must have @ escaped, as this is + * used as the delimiter with preg_match() + * + * For high-level routing purposes, consider using + * + * @since 4.4.0 + * @since 5.4.0 Added `$route_namespace` parameter. + * + * @param string $route_namespace Optionally, only return routes in the given namespace. + * @return array `'/path/regex' => array( $callback, $bitmask )` or + * `'/path/regex' => array( array( $callback, $bitmask ), ...)`. + */ + public function get_routes( $route_namespace = '' ) { + $endpoints = $this->get_unresolved_routes( $route_namespace ); - foreach ( $methods as $method ) { - $method = strtoupper( trim( $method ) ); - $handler['methods'][ $method ] = true; - } - } + // Resolve the routes. + foreach ( $endpoints as $route => &$handlers ) { + $handlers = $this->resolve_route_handlers( $route, $handlers ); } return $endpoints; @@ -1152,17 +1198,17 @@ protected function match_request_to_handler( $request ) { foreach ( $this->get_namespaces() as $namespace ) { if ( str_starts_with( trailingslashit( ltrim( $path, '/' ) ), $namespace ) ) { - $with_namespace[] = $this->get_routes( $namespace ); + $with_namespace[] = $this->get_unresolved_routes( $namespace ); } } if ( $with_namespace ) { $routes = array_merge( ...$with_namespace ); } else { - $routes = $this->get_routes(); + $routes = $this->get_unresolved_routes(); } - foreach ( $routes as $route => $handlers ) { + foreach ( $routes as $route => $resolvable ) { $match = preg_match( '@^' . $route . '$@i', $path, $matches ); if ( ! $match ) { @@ -1177,6 +1223,7 @@ protected function match_request_to_handler( $request ) { } } + $handlers = $this->resolve_route_handlers( $route, $resolvable ); foreach ( $handlers as $handler ) { $callback = $handler['callback']; diff --git a/src/wp-settings.php b/src/wp-settings.php index dab1d8fd4c0de..52d3c8af0f23c 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -318,6 +318,7 @@ require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-request.php'; +require ABSPATH . WPINC . '/rest-api/class-wp-rest-resolvable-route.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-posts-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-attachments-controller.php'; diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index 90de3e13eecea..c88208a090f9a 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -1046,6 +1046,81 @@ public function test_rest_filter_response_by_context( $schema, $data, $expected $this->assertSame( $expected, rest_filter_response_by_context( $data, $schema, 'view' ) ); } + public function test_register_route_with_resolvable_options_closure() { + register_rest_route( + 'my-ns/v1', + '/my-route', + static function () { + return array( + 'callback' => '__return_true', + 'permission_callback' => '__return_true', + ); + } + ); + + $routes = rest_get_server()->get_routes( 'my-ns/v1' ); + $this->assertCount( 2, $routes ); + + $this->assertTrue( rest_do_request( '/my-ns/v1/my-route' )->get_data() ); + } + + public function test_register_route_with_resolvable_options_arrow() { + register_rest_route( + 'my-ns/v1', + '/my-route', + static fn () => array( + 'callback' => '__return_true', + 'permission_callback' => '__return_true', + ) + ); + + $routes = rest_get_server()->get_routes( 'my-ns/v1' ); + $this->assertCount( 2, $routes ); + + $this->assertTrue( rest_do_request( '/my-ns/v1/my-route' )->get_data() ); + } + + public function test_register_route_with_resolvable_options_named_function() { + function test_register_route_with_resolvable_options_named_function__options() { + return array( + 'callback' => '__return_true', + 'permission_callback' => '__return_true', + ); + } + register_rest_route( + 'my-ns/v1', + '/my-route', + 'test_register_route_with_resolvable_options_named_function__options' + ); + + $routes = rest_get_server()->get_routes( 'my-ns/v1' ); + $this->assertCount( 2, $routes ); + + $this->assertTrue( rest_do_request( '/my-ns/v1/my-route' )->get_data() ); + } + + public function test_register_route_with_resolvable_options_method() { + $obj = new class() { + public function get_options() { + return array( + 'callback' => '__return_true', + 'permission_callback' => '__return_true', + ); + } + }; + + register_rest_route( + 'my-ns/v1', + '/my-route', + [ $obj, 'get_options' ] + ); + + $routes = rest_get_server()->get_routes( 'my-ns/v1' ); + $this->assertCount( 2, $routes ); + + $this->assertTrue( rest_do_request( '/my-ns/v1/my-route' )->get_data() ); + } + /** * @ticket 49749 */ From dc64a0c7e704fa29cbcba2f15b943ea0a1b4091c Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 20 Apr 2026 16:29:31 +0200 Subject: [PATCH 2/6] Use ReturnTypeWillChange for PHP 7.4 compat --- .../rest-api/class-wp-rest-resolvable-route.php | 12 ++++++++---- src/wp-includes/rest-api/class-wp-rest-server.php | 5 +++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php index a5def3c46ef4c..e0b5e6b3f8907 100644 --- a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php +++ b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php @@ -74,7 +74,8 @@ public function __invoke() { * @param string $key The key to check. * @return bool True if the key exists, false otherwise. */ - public function offsetExists( mixed $k ) : bool { + #[ReturnTypeWillChange] + public function offsetExists( mixed $k ) { $this->__invoke(); return isset( $this->resolved[ $k ] ); } @@ -87,7 +88,8 @@ public function offsetExists( mixed $k ) : bool { * @param string $key The key to retrieve. * @return mixed The value of the key, or null if not set. Returns by reference, so it can be modified if needed. */ - public function &offsetGet( mixed $k ) : mixed { + #[ReturnTypeWillChange] + public function &offsetGet( mixed $k ) { $this->__invoke(); return $this->resolved[ $k ]; } @@ -100,7 +102,8 @@ public function &offsetGet( mixed $k ) : mixed { * @param string $key The key to set. * @param mixed $value The value to set. */ - public function offsetSet( mixed $k, mixed $v ) : void { + #[ReturnTypeWillChange] + public function offsetSet( mixed $k, mixed $v ) { $this->__invoke(); $this->resolved[ $k ] = $v; } @@ -112,7 +115,8 @@ public function offsetSet( mixed $k, mixed $v ) : void { * * @param string $key The key to unset. */ - public function offsetUnset( mixed $k ) : void { + #[ReturnTypeWillChange] + public function offsetUnset( mixed $k ) { $this->__invoke(); unset( $this->resolved[ $k ] ); } diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index e61362ded3eda..f843123fba0f1 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -957,8 +957,9 @@ public function get_unresolved_routes( $route_namespace = '' ) { * * @param array $endpoints The available endpoints. An array of matching regex patterns, each mapped * to an array of callbacks for the endpoint. These take the format - * `'/path/regex' => array( $callback, $bitmask )` or - * `'/path/regex' => array( array( $callback, $bitmask ). + * `'/path/regex' => array( $callback, $bitmask )`, + * `'/path/regex' => array( array( $callback, $bitmask ), or + * `'/path/regex' => object(WP_REST_Resolvable_Route) */ $endpoints = apply_filters( 'rest_endpoints', $endpoints ); From 0970b430413baa090605ce47b57cad6f72b67e25 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 20 Apr 2026 16:30:04 +0200 Subject: [PATCH 3/6] Correct spaces to tabs --- .../class-wp-rest-resolvable-route.php | 74 +++++++++---------- .../rest-api/class-wp-rest-server.php | 7 +- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php index e0b5e6b3f8907..84f4088c2bad8 100644 --- a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php +++ b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php @@ -22,7 +22,7 @@ class WP_REST_Resolvable_Route implements ArrayAccess, IteratorAggregate, Counta * @since X.X.0 * @var callable */ - protected $callable; + protected $callable; /** * The resolved route definition. @@ -30,7 +30,7 @@ class WP_REST_Resolvable_Route implements ArrayAccess, IteratorAggregate, Counta * @since X.X.0 * @var array|null */ - protected $resolved = null; + protected $resolved = null; /** * Constructor. @@ -39,11 +39,11 @@ class WP_REST_Resolvable_Route implements ArrayAccess, IteratorAggregate, Counta * * @param callable $closure The callable used to resolve the route. Returns a single route definition. */ - public function __construct( string $namespace, string $route, callable $closure ) { + public function __construct( string $namespace, string $route, callable $closure ) { $this->namespace = $namespace; $this->route = $route; - $this->callable = $closure; - } + $this->callable = $closure; + } /** * Invokes the callable to resolve, if needed. @@ -56,15 +56,15 @@ public function __construct( string $namespace, string $route, callable $closure * * @return array The resolved route definition. */ - public function __invoke() { - if ( ! $this->resolved ) { - $this->resolved = call_user_func( $this->callable ); + public function __invoke() { + if ( ! $this->resolved ) { + $this->resolved = call_user_func( $this->callable ); - // Normalize the result. + // Normalize the result. $this->resolved = normalize_rest_endpoint_options( $this->namespace, $this->route, $this->resolved ); - } - return $this->resolved; - } + } + return $this->resolved; + } /** * Checks a single array key exists in the resolved route definition. @@ -75,10 +75,10 @@ public function __invoke() { * @return bool True if the key exists, false otherwise. */ #[ReturnTypeWillChange] - public function offsetExists( mixed $k ) { - $this->__invoke(); - return isset( $this->resolved[ $k ] ); - } + public function offsetExists( mixed $k ) { + $this->__invoke(); + return isset( $this->resolved[ $k ] ); + } /** * Gets a single array key from the resolved route definition. @@ -89,10 +89,10 @@ public function offsetExists( mixed $k ) { * @return mixed The value of the key, or null if not set. Returns by reference, so it can be modified if needed. */ #[ReturnTypeWillChange] - public function &offsetGet( mixed $k ) { - $this->__invoke(); - return $this->resolved[ $k ]; - } + public function &offsetGet( mixed $k ) { + $this->__invoke(); + return $this->resolved[ $k ]; + } /** * Sets a single array key in the resolved route definition. @@ -103,10 +103,10 @@ public function &offsetGet( mixed $k ) { * @param mixed $value The value to set. */ #[ReturnTypeWillChange] - public function offsetSet( mixed $k, mixed $v ) { - $this->__invoke(); - $this->resolved[ $k ] = $v; - } + public function offsetSet( mixed $k, mixed $v ) { + $this->__invoke(); + $this->resolved[ $k ] = $v; + } /** * Unsets a single array key in the resolved route definition. @@ -116,10 +116,10 @@ public function offsetSet( mixed $k, mixed $v ) { * @param string $key The key to unset. */ #[ReturnTypeWillChange] - public function offsetUnset( mixed $k ) { - $this->__invoke(); - unset( $this->resolved[ $k ] ); - } + public function offsetUnset( mixed $k ) { + $this->__invoke(); + unset( $this->resolved[ $k ] ); + } /** * Gets an iterator for the resolved route definition. @@ -128,20 +128,20 @@ public function offsetUnset( mixed $k ) { * * @return Traversable An iterator for the resolved route definition. */ - public function getIterator(): Traversable { - $this->__invoke(); - return new ArrayIterator( $this->resolved ); - } + public function getIterator(): Traversable { + $this->__invoke(); + return new ArrayIterator( $this->resolved ); + } - /** + /** * Counts the number of elements in the resolved route definition. * * @since X.X.0 * * @return int The number of elements in the resolved route definition. */ - public function count() : int { - $this->__invoke(); - return count( $this->resolved ); - } + public function count() : int { + $this->__invoke(); + return count( $this->resolved ); + } } diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index f843123fba0f1..7efec23823e76 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -940,8 +940,9 @@ public function register_route( $route_namespace, $route, $route_args, $override * @since X.X.0 * * @param string $route_namespace Optionally, only return routes in the given namespace. - * @return array `'/path/regex' => array( $callback, $bitmask )` or - * `'/path/regex' => array( array( $callback, $bitmask ), ...)`. + * @return array `'/path/regex' => array( $callback, $bitmask )`, + * `'/path/regex' => array( array( $callback, $bitmask ), ...)`, or + * `'/path/regex' => object( WP_REST_Resolvable_Route )` */ public function get_unresolved_routes( $route_namespace = '' ) { $endpoints = $this->endpoints; @@ -959,7 +960,7 @@ public function get_unresolved_routes( $route_namespace = '' ) { * to an array of callbacks for the endpoint. These take the format * `'/path/regex' => array( $callback, $bitmask )`, * `'/path/regex' => array( array( $callback, $bitmask ), or - * `'/path/regex' => object(WP_REST_Resolvable_Route) + * `'/path/regex' => object( WP_REST_Resolvable_Route )` */ $endpoints = apply_filters( 'rest_endpoints', $endpoints ); From fd83d3596681aaef6b409db2013e4ebade2e7355 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 20 Apr 2026 16:32:35 +0200 Subject: [PATCH 4/6] Ensure compatibility with PHP <8.2 --- src/wp-includes/rest-api/class-wp-rest-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 7efec23823e76..00446ca21f198 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1010,7 +1010,7 @@ public function resolve_route_handlers( string $route, array &$handlers ) { } // Resolve any just-in-time resolvable options, and apply defaults. - $handler = iterator_to_array( $handler ); + $handler = is_array( $handler ) ? $handler : iterator_to_array( $handler ); $handler = wp_parse_args( $handler, $defaults ); // Allow comma-separated HTTP methods. From 334b207e0dd798007e17f5ae4fd9d48c9dc24cf1 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 20 Apr 2026 16:39:24 +0200 Subject: [PATCH 5/6] Remove more incompatible typehints --- .../rest-api/class-wp-rest-resolvable-route.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php index 84f4088c2bad8..09cf54eb53632 100644 --- a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php +++ b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php @@ -75,7 +75,7 @@ public function __invoke() { * @return bool True if the key exists, false otherwise. */ #[ReturnTypeWillChange] - public function offsetExists( mixed $k ) { + public function offsetExists( $k ) { $this->__invoke(); return isset( $this->resolved[ $k ] ); } @@ -89,7 +89,7 @@ public function offsetExists( mixed $k ) { * @return mixed The value of the key, or null if not set. Returns by reference, so it can be modified if needed. */ #[ReturnTypeWillChange] - public function &offsetGet( mixed $k ) { + public function &offsetGet( $k ) { $this->__invoke(); return $this->resolved[ $k ]; } @@ -103,7 +103,7 @@ public function &offsetGet( mixed $k ) { * @param mixed $value The value to set. */ #[ReturnTypeWillChange] - public function offsetSet( mixed $k, mixed $v ) { + public function offsetSet( $k, $v ) { $this->__invoke(); $this->resolved[ $k ] = $v; } @@ -116,7 +116,7 @@ public function offsetSet( mixed $k, mixed $v ) { * @param string $key The key to unset. */ #[ReturnTypeWillChange] - public function offsetUnset( mixed $k ) { + public function offsetUnset( $k ) { $this->__invoke(); unset( $this->resolved[ $k ] ); } From 58631bb46bd7df611b6be87e43da3ff62c99d615 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 20 Apr 2026 16:43:29 +0200 Subject: [PATCH 6/6] Correct phpDoc and fix coding standard issue --- src/wp-includes/rest-api.php | 4 +++- src/wp-includes/rest-api/class-wp-rest-resolvable-route.php | 2 +- src/wp-includes/rest-api/class-wp-rest-server.php | 6 ++++-- tests/phpunit/tests/rest-api.php | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 4d0aa850e6e0b..461d0626b5c6b 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -127,12 +127,14 @@ function register_rest_route( $route_namespace, $route, $args = array(), $overri /** * Normalize the options for a single REST API endpoint. * + * @since X.X.0 + * * @param string $namespace The route namespace. * @param string $route The route. * @param array $endpoint The endpoint options. * @param array $common_args Common arguments to merge with endpoint-specific arguments. */ -function normalize_rest_endpoint_options( string $namespace, string $route, array $endpoint, array $common_args = [] ) { +function normalize_rest_endpoint_options( string $namespace, string $route, array $endpoint, array $common_args = array() ) { $defaults = array( 'methods' => 'GET', 'callback' => null, diff --git a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php index 09cf54eb53632..ac1ac6c972e6a 100644 --- a/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php +++ b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php @@ -140,7 +140,7 @@ public function getIterator(): Traversable { * * @return int The number of elements in the resolved route definition. */ - public function count() : int { + public function count(): int { $this->__invoke(); return count( $this->resolved ); } diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 00446ca21f198..ec171c4ff6b65 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -977,7 +977,7 @@ public function get_unresolved_routes( $route_namespace = '' ) { * * @since X.X.0 * - * @param string $route The route to normalize. + * @param string $route The route to normalize. * @param array $handlers Route option handlers to normalize. * @return array `'/path/regex' => array( $callback, $bitmask )` or * `'/path/regex' => array( array( $callback, $bitmask ), ...)`. @@ -1047,7 +1047,9 @@ public function resolve_route_handlers( string $route, array &$handlers ) { * Note that the path regexes (array keys) must have @ escaped, as this is * used as the delimiter with preg_match() * - * For high-level routing purposes, consider using + * For high-level routing purposes, consider using + * WP_REST_Server::get_unresolved_routes() instead, which can be more + * performant when you only need a list of registered routes. * * @since 4.4.0 * @since 5.4.0 Added `$route_namespace` parameter. diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index c88208a090f9a..2a10a206481f7 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -1112,7 +1112,7 @@ public function get_options() { register_rest_route( 'my-ns/v1', '/my-route', - [ $obj, 'get_options' ] + array( $obj, 'get_options' ) ); $routes = rest_get_server()->get_routes( 'my-ns/v1' );