diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php
index c524f9e22a12f..461d0626b5c6b 100644
--- a/src/wp-includes/rest-api.php
+++ b/src/wp-includes/rest-api.php
@@ -92,67 +92,88 @@ 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;
+}
+
+/**
+ * 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 = array() ) {
+ $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'
+ );
+ }
- if ( ! isset( $arg_group['permission_callback'] ) ) {
+ 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..ac1ac6c972e6a
--- /dev/null
+++ b/src/wp-includes/rest-api/class-wp-rest-resolvable-route.php
@@ -0,0 +1,147 @@
+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.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetExists( $k ) {
+ $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.
+ */
+ #[ReturnTypeWillChange]
+ public function &offsetGet( $k ) {
+ $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.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetSet( $k, $v ) {
+ $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.
+ */
+ #[ReturnTypeWillChange]
+ public function offsetUnset( $k ) {
+ $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..ec171c4ff6b65 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,24 @@ 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 ), ...)`.
+ * @return array `'/path/regex' => array( $callback, $bitmask )`,
+ * `'/path/regex' => array( array( $callback, $bitmask ), ...)`, or
+ * `'/path/regex' => object( WP_REST_Resolvable_Route )`
*/
- public function get_routes( $route_namespace = '' ) {
+ public function get_unresolved_routes( $route_namespace = '' ) {
$endpoints = $this->endpoints;
if ( $route_namespace ) {
@@ -962,11 +958,31 @@ public function get_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 );
+ 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 +992,78 @@ 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 = is_array( $handler ) ? $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
+ * 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.
+ *
+ * @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 +1202,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 +1227,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..2a10a206481f7 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',
+ array( $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
*/