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 */