From d803f6bf8228625a384dbb694337c7d7957f1ec3 Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Sat, 5 Sep 2020 21:50:31 +0000 Subject: [PATCH] REST API: Refactor `WP_REST_Server::dispatch()` to make internal logic reusable. #50244 aims to introduce batch processing in the REST API. An important feature is the ability to enforce that all requests have valid data before executing the route callbacks in "pre-validate" mode. This necessitates splitting `WP_REST_Server::dispatch()` into two methods so the batch controller can determine the request handler to perform pre-validation and then respond to the requests. The two new methods, `match_request_to_handler` and `respond_to_request`, have a public visibility, but are marked as `@access private`. This is to allow for iteration on the batch controller to happen in the Gutenberg repository. Developers should not rely upon these methods, their visibility may change in the future. See #50244. Props andraganescu, zieladam, TimothyBlynJacobs. git-svn-id: https://develop.svn.wordpress.org/trunk@48947 602fd350-edb4-49c9-b593-d223f7449a82 --- .../rest-api/class-wp-rest-server.php | 284 ++++++++++-------- tests/phpunit/tests/rest-api/rest-server.php | 104 +++++++ 2 files changed, 264 insertions(+), 124 deletions(-) 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 6b94094041..3cc26d0573 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -911,6 +911,48 @@ class WP_REST_Server { return $result; } + $error = null; + $matched = $this->match_request_to_handler( $request ); + + if ( is_wp_error( $matched ) ) { + return $this->error_to_response( $matched ); + } + + list( $route, $handler ) = $matched; + + if ( ! is_callable( $handler['callback'] ) ) { + $error = new WP_Error( + 'rest_invalid_handler', + __( 'The handler for the route is invalid' ), + array( 'status' => 500 ) + ); + } + + if ( ! is_wp_error( $error ) ) { + $check_required = $request->has_valid_params(); + if ( is_wp_error( $check_required ) ) { + $error = $check_required; + } else { + $check_sanitized = $request->sanitize_params(); + if ( is_wp_error( $check_sanitized ) ) { + $error = $check_sanitized; + } + } + } + + return $this->respond_to_request( $request, $route, $handler, $error ); + } + + /** + * Matches a request object to it's handler. + * + * @access private + * @since 5.6.0 + * + * @param WP_REST_Request $request The request object. + * @return array|WP_Error The route and request handler on success or a WP_Error instance if no handler was found. + */ + public function match_request_to_handler( $request ) { $method = $request->get_method(); $path = $request->get_route(); @@ -957,144 +999,138 @@ class WP_REST_Server { } if ( ! is_callable( $callback ) ) { - $response = new WP_Error( - 'rest_invalid_handler', - __( 'The handler for the route is invalid' ), - array( 'status' => 500 ) - ); + return array( $route, $handler ); } - if ( ! is_wp_error( $response ) ) { - // Remove the redundant preg_match argument. - unset( $args[0] ); + $request->set_url_params( $args ); + $request->set_attributes( $handler ); - $request->set_url_params( $args ); - $request->set_attributes( $handler ); + $defaults = array(); - $defaults = array(); - - foreach ( $handler['args'] as $arg => $options ) { - if ( isset( $options['default'] ) ) { - $defaults[ $arg ] = $options['default']; - } - } - - $request->set_default_params( $defaults ); - - $check_required = $request->has_valid_params(); - if ( is_wp_error( $check_required ) ) { - $response = $check_required; - } else { - $check_sanitized = $request->sanitize_params(); - if ( is_wp_error( $check_sanitized ) ) { - $response = $check_sanitized; - } + foreach ( $handler['args'] as $arg => $options ) { + if ( isset( $options['default'] ) ) { + $defaults[ $arg ] = $options['default']; } } - /** - * Filters the response before executing any REST API callbacks. - * - * Allows plugins to perform additional validation after a - * request is initialized and matched to a registered route, - * but before it is executed. - * - * Note that this filter will not be called for requests that - * fail to authenticate or match to a registered route. - * - * @since 4.7.0 - * - * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response or WP_Error. - * @param array $handler Route handler used for the request. - * @param WP_REST_Request $request Request used to generate the response. - */ - $response = apply_filters( 'rest_request_before_callbacks', $response, $handler, $request ); + $request->set_default_params( $defaults ); - if ( ! is_wp_error( $response ) ) { - // Check permission specified on the route. - if ( ! empty( $handler['permission_callback'] ) ) { - $permission = call_user_func( $handler['permission_callback'], $request ); - - if ( is_wp_error( $permission ) ) { - $response = $permission; - } elseif ( false === $permission || null === $permission ) { - $response = new WP_Error( - 'rest_forbidden', - __( 'Sorry, you are not allowed to do that.' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - } - } - - if ( ! is_wp_error( $response ) ) { - /** - * Filters the REST dispatch request result. - * - * Allow plugins to override dispatching the request. - * - * @since 4.4.0 - * @since 4.5.0 Added `$route` and `$handler` parameters. - * - * @param mixed $dispatch_result Dispatch result, will be used if not empty. - * @param WP_REST_Request $request Request used to generate the response. - * @param string $route Route matched for the request. - * @param array $handler Route handler used for the request. - */ - $dispatch_result = apply_filters( 'rest_dispatch_request', null, $request, $route, $handler ); - - // Allow plugins to halt the request via this filter. - if ( null !== $dispatch_result ) { - $response = $dispatch_result; - } else { - $response = call_user_func( $callback, $request ); - } - } - - /** - * Filters the response immediately after executing any REST API - * callbacks. - * - * Allows plugins to perform any needed cleanup, for example, - * to undo changes made during the {@see 'rest_request_before_callbacks'} - * filter. - * - * Note that this filter will not be called for requests that - * fail to authenticate or match to a registered route. - * - * Note that an endpoint's `permission_callback` can still be - * called after this filter - see `rest_send_allow_header()`. - * - * @since 4.7.0 - * - * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response or WP_Error. - * @param array $handler Route handler used for the request. - * @param WP_REST_Request $request Request used to generate the response. - */ - $response = apply_filters( 'rest_request_after_callbacks', $response, $handler, $request ); - - if ( is_wp_error( $response ) ) { - $response = $this->error_to_response( $response ); - } else { - $response = rest_ensure_response( $response ); - } - - $response->set_matched_route( $route ); - $response->set_matched_handler( $handler ); - - return $response; + return array( $route, $handler ); } } - return $this->error_to_response( - new WP_Error( - 'rest_no_route', - __( 'No route was found matching the URL and request method' ), - array( 'status' => 404 ) - ) + return new WP_Error( + 'rest_no_route', + __( 'No route was found matching the URL and request method' ), + array( 'status' => 404 ) ); } + /** + * Dispatches the request to the callback handler. + * + * @access private + * @since 5.6.0 + * + * @param WP_REST_Request $request The request object. + * @param array $handler The matched route handler. + * @param string $route The matched route regex. + * @param WP_Error|null $response The current error object if any. + * + * @return WP_REST_Response + */ + public function respond_to_request( $request, $route, $handler, $response ) { + /** + * Filters the response before executing any REST API callbacks. + * + * Allows plugins to perform additional validation after a + * request is initialized and matched to a registered route, + * but before it is executed. + * + * Note that this filter will not be called for requests that + * fail to authenticate or match to a registered route. + * + * @since 4.7.0 + * + * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response or WP_Error. + * @param array $handler Route handler used for the request. + * @param WP_REST_Request $request Request used to generate the response. + */ + $response = apply_filters( 'rest_request_before_callbacks', $response, $handler, $request ); + + // Check permission specified on the route. + if ( ! is_wp_error( $response ) && ! empty( $handler['permission_callback'] ) ) { + $permission = call_user_func( $handler['permission_callback'], $request ); + + if ( is_wp_error( $permission ) ) { + $response = $permission; + } elseif ( false === $permission || null === $permission ) { + $response = new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to do that.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + if ( ! is_wp_error( $response ) ) { + /** + * Filters the REST dispatch request result. + * + * Allow plugins to override dispatching the request. + * + * @since 4.4.0 + * @since 4.5.0 Added `$route` and `$handler` parameters. + * + * @param mixed $dispatch_result Dispatch result, will be used if not empty. + * @param WP_REST_Request $request Request used to generate the response. + * @param string $route Route matched for the request. + * @param array $handler Route handler used for the request. + */ + $dispatch_result = apply_filters( 'rest_dispatch_request', null, $request, $route, $handler ); + + // Allow plugins to halt the request via this filter. + if ( null !== $dispatch_result ) { + $response = $dispatch_result; + } else { + $response = call_user_func( $handler['callback'], $request ); + } + } + + /** + * Filters the response immediately after executing any REST API + * callbacks. + * + * Allows plugins to perform any needed cleanup, for example, + * to undo changes made during the {@see 'rest_request_before_callbacks'} + * filter. + * + * Note that this filter will not be called for requests that + * fail to authenticate or match to a registered route. + * + * Note that an endpoint's `permission_callback` can still be + * called after this filter - see `rest_send_allow_header()`. + * + * @since 4.7.0 + * + * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response or WP_Error. + * @param array $handler Route handler used for the request. + * @param WP_REST_Request $request Request used to generate the response. + */ + $response = apply_filters( 'rest_request_after_callbacks', $response, $handler, $request ); + + if ( is_wp_error( $response ) ) { + $response = $this->error_to_response( $response ); + } else { + $response = rest_ensure_response( $response ); + } + + $response->set_matched_route( $route ); + $response->set_matched_handler( $handler ); + + return $response; + } + /** * Returns if an error occurred during most recent JSON encode/decode. * diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 94d0f0c6df..bfd5959449 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -1513,6 +1513,110 @@ class Tests_REST_Server extends WP_Test_REST_TestCase { $this->assertSame( 204, $response->get_status(), '/test-ns/v1/test' ); } + /** + * @ticket 50244 + */ + public function test_no_route() { + $mock_hook = new MockAction(); + add_filter( 'rest_request_after_callbacks', array( $mock_hook, 'filter' ) ); + + $response = rest_do_request( '/test-ns/v1/test' ); + $this->assertErrorResponse( 'rest_no_route', $response, 404 ); + + // Verify that the no route error was not filtered. + $this->assertCount( 0, $mock_hook->get_events() ); + } + + /** + * @ticket 50244 + */ + public function test_invalid_handler() { + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'callback' => 'invalid_callback', + 'permission_callback' => '__return_true', + ) + ); + + $mock_hook = new MockAction(); + add_filter( 'rest_request_after_callbacks', array( $mock_hook, 'filter' ) ); + + $response = rest_do_request( '/test-ns/v1/test' ); + $this->assertErrorResponse( 'rest_invalid_handler', $response, 500 ); + + // Verify that the invalid handler error was filtered. + $events = $mock_hook->get_events(); + $this->assertCount( 1, $events ); + $this->assertWPError( $events[0]['args'][0] ); + $this->assertEquals( 'rest_invalid_handler', $events[0]['args'][0]->get_error_code() ); + } + + /** + * @ticket 50244 + */ + public function test_callbacks_are_not_executed_if_request_validation_fails() { + $callback = $this->createPartialMock( 'stdClass', array( '__invoke' ) ); + $callback->expects( self::never() )->method( '__invoke' ); + $permission_callback = $this->createPartialMock( 'stdClass', array( '__invoke' ) ); + $permission_callback->expects( self::never() )->method( '__invoke' ); + + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'callback' => $callback, + 'permission_callback' => $permission_callback, + 'args' => array( + 'test' => array( + 'validate_callback' => '__return_false', + ), + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/test-ns/v1/test' ); + $request->set_query_params( array( 'test' => 'world' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * @ticket 50244 + */ + public function test_filters_are_executed_if_request_validation_fails() { + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'callback' => '__return_empty_array', + 'permission_callback' => '__return_true', + 'args' => array( + 'test' => array( + 'validate_callback' => '__return_false', + ), + ), + ) + ); + + $mock_hook = new MockAction(); + add_filter( 'rest_request_after_callbacks', array( $mock_hook, 'filter' ) ); + + $request = new WP_REST_Request( 'GET', '/test-ns/v1/test' ); + $request->set_query_params( array( 'test' => 'world' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + // Verify that the invalid param error was filtered. + $events = $mock_hook->get_events(); + $this->assertCount( 1, $events ); + $this->assertWPError( $events[0]['args'][0] ); + $this->assertEquals( 'rest_invalid_param', $events[0]['args'][0]->get_error_code() ); + } + public function _validate_as_integer_123( $value, $request, $key ) { if ( ! is_int( $value ) ) { return new WP_Error( 'some-error', 'This is not valid!' );