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 4c0555d3ad..0844819568 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -94,7 +94,7 @@ class WP_REST_Server { public function __construct() { $this->endpoints = array( // Meta endpoints. - '/' => array( + '/' => array( 'callback' => array( $this, 'get_index' ), 'methods' => 'GET', 'args' => array( @@ -103,6 +103,51 @@ class WP_REST_Server { ), ), ), + '/batch/v1' => array( + 'callback' => array( $this, 'serve_batch_request_v1' ), + 'methods' => 'POST', + 'args' => array( + 'validation' => array( + 'type' => 'string', + 'enum' => array( 'require-all-validate', 'normal' ), + 'default' => 'normal', + ), + 'requests' => array( + 'required' => true, + 'type' => 'array', + 'maxItems' => $this->get_max_batch_size(), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'method' => array( + 'type' => 'string', + 'enum' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ), + 'default' => 'POST', + ), + 'path' => array( + 'type' => 'string', + 'required' => true, + ), + 'body' => array( + 'type' => 'object', + 'properties' => array(), + 'additionalProperties' => true, + ), + 'headers' => array( + 'type' => 'object', + 'properties' => array(), + 'additionalProperties' => array( + 'type' => array( 'string', 'array' ), + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + ), + ), + ), ); } @@ -971,7 +1016,7 @@ class WP_REST_Server { * @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 ) { + protected function match_request_to_handler( $request ) { $method = $request->get_method(); $path = $request->get_route(); @@ -1058,7 +1103,7 @@ class WP_REST_Server { * * @return WP_REST_Response */ - public function respond_to_request( $request, $route, $handler, $response ) { + protected function respond_to_request( $request, $route, $handler, $response ) { /** * Filters the response before executing any REST API callbacks. * @@ -1396,6 +1441,178 @@ class WP_REST_Server { return $data; } + /** + * Gets the maximum number of requests that can be included in a batch. + * + * @since 5.6.0 + * + * @return int The maximum requests. + */ + protected function get_max_batch_size() { + /** + * Filters the maximum number of requests that can be included in a batch. + * + * @param int $max_size The maximum size. + */ + return apply_filters( 'rest_get_max_batch_size', 25 ); + } + + /** + * Serves the batch/v1 request. + * + * @since 5.6.0 + * + * @param WP_REST_Request $batch_request The batch request object. + * @return WP_REST_Response The generated response object. + */ + public function serve_batch_request_v1( WP_REST_Request $batch_request ) { + $requests = array(); + + foreach ( $batch_request['requests'] as $args ) { + $parsed_url = wp_parse_url( $args['path'] ); + + if ( false === $parsed_url ) { + $requests[] = new WP_Error( 'parse_path_failed', __( 'Could not parse the path.' ), array( 'status' => 400 ) ); + + continue; + } + + $single_request = new WP_REST_Request( isset( $args['method'] ) ? $args['method'] : 'POST', $parsed_url['path'] ); + + if ( ! empty( $parsed_url['query'] ) ) { + $query_args = null; // Satisfy linter. + wp_parse_str( $parsed_url['query'], $query_args ); + $single_request->set_query_params( $query_args ); + } + + if ( ! empty( $args['body'] ) ) { + $single_request->set_body_params( $args['body'] ); + } + + if ( ! empty( $args['headers'] ) ) { + $single_request->set_headers( $args['headers'] ); + } + + $requests[] = $single_request; + } + + $matches = array(); + $validation = array(); + $has_error = false; + + foreach ( $requests as $single_request ) { + $match = $this->match_request_to_handler( $single_request ); + $matches[] = $match; + $error = null; + + if ( is_wp_error( $match ) ) { + $error = $match; + } + + if ( ! $error ) { + list( $route, $handler ) = $match; + + if ( isset( $handler['allow_batch'] ) ) { + $allow_batch = $handler['allow_batch']; + } else { + $route_options = $this->get_route_options( $route ); + $allow_batch = isset( $route_options['allow_batch'] ) ? $route_options['allow_batch'] : false; + } + + if ( ! is_array( $allow_batch ) || empty( $allow_batch['v1'] ) ) { + $error = new WP_Error( + 'rest_batch_not_allowed', + __( 'The requested route does not support batch requests.' ), + array( 'status' => 400 ) + ); + } + } + + if ( ! $error ) { + $check_required = $single_request->has_valid_params(); + if ( is_wp_error( $check_required ) ) { + $error = $check_required; + } + } + + if ( ! $error ) { + $check_sanitized = $single_request->sanitize_params(); + if ( is_wp_error( $check_sanitized ) ) { + $error = $check_sanitized; + } + } + + if ( $error ) { + $has_error = true; + $validation[] = $error; + } else { + $validation[] = true; + } + } + + $responses = array(); + + if ( $has_error && 'require-all-validate' === $batch_request['validation'] ) { + foreach ( $validation as $valid ) { + if ( is_wp_error( $valid ) ) { + $responses[] = $this->envelope_response( $this->error_to_response( $valid ), false )->get_data(); + } else { + $responses[] = null; + } + } + + return new WP_REST_Response( + array( + 'failed' => 'validation', + 'responses' => $responses, + ), + WP_Http::MULTI_STATUS + ); + } + + foreach ( $requests as $i => $single_request ) { + $clean_request = clone $single_request; + $clean_request->set_url_params( array() ); + $clean_request->set_attributes( array() ); + $clean_request->set_default_params( array() ); + + /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ + $result = apply_filters( 'rest_pre_dispatch', null, $this, $clean_request ); + + if ( empty( $result ) ) { + $match = $matches[ $i ]; + $error = null; + + if ( is_wp_error( $validation[ $i ] ) ) { + $error = $validation[ $i ]; + } + + if ( is_wp_error( $match ) ) { + $result = $this->error_to_response( $match ); + } else { + list( $route, $handler ) = $match; + + if ( ! $error && ! is_callable( $handler['callback'] ) ) { + $error = new WP_Error( + 'rest_invalid_handler', + __( 'The handler for the route is invalid' ), + array( 'status' => 500 ) + ); + } + + $result = $this->respond_to_request( $single_request, $route, $handler, $error ); + } + } + + /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */ + $result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), $this, $single_request ); + + $responses[] = $this->envelope_response( $result, false )->get_data(); + } + + return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS ); + } + /** * Sends an HTTP status code. * diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index bfd5959449..9348e315c2 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -1617,6 +1617,320 @@ class Tests_REST_Server extends WP_Test_REST_TestCase { $this->assertEquals( 'rest_invalid_param', $events[0]['args'][0]->get_error_code() ); } + /** + * @ticket 50244 + * @dataProvider data_batch_v1_optin + */ + public function test_batch_v1_optin( $allow_batch, $allowed ) { + $args = array( + 'methods' => 'POST', + 'callback' => static function () { + return new WP_REST_Response( 'data' ); + }, + 'permission_callback' => '__return_true', + ); + + if ( null !== $allow_batch ) { + $args['allow_batch'] = $allow_batch; + } + + register_rest_route( + 'test-ns/v1', + '/test', + $args + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'requests' => array( + array( + 'path' => '/test-ns/v1/test', + ), + ), + ) + ); + + $response = rest_do_request( $request ); + + $this->assertEquals( 207, $response->get_status() ); + + if ( $allowed ) { + $this->assertEquals( 'data', $response->get_data()['responses'][0]['body'] ); + } else { + $this->assertEquals( 'rest_batch_not_allowed', $response->get_data()['responses'][0]['body']['code'] ); + } + } + + public function data_batch_v1_optin() { + return array( + 'missing' => array( null, false ), + 'invalid type' => array( true, false ), + 'invalid type string' => array( 'v1', false ), + 'wrong version' => array( array( 'version1' => true ), false ), + 'false version' => array( array( 'v1' => false ), false ), + 'valid' => array( array( 'v1' => true ), true ), + ); + } + + /** + * @ticket 50244 + */ + public function test_batch_v1_pre_validation() { + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'methods' => 'POST', + 'callback' => static function ( $request ) { + $project = $request['project']; + update_option( 'test_project', $project ); + + return new WP_REST_Response( $project ); + }, + 'permission_callback' => '__return_true', + 'allow_batch' => array( 'v1' => true ), + 'args' => array( + 'project' => array( + 'type' => 'string', + 'enum' => array( 'gutenberg', 'WordPress' ), + ), + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'validation' => 'require-all-validate', + 'requests' => array( + array( + 'path' => '/test-ns/v1/test', + 'body' => array( + 'project' => 'gutenberg', + ), + ), + array( + 'path' => '/test-ns/v1/test', + 'body' => array( + 'project' => 'buddypress', + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 207, $response->get_status() ); + $this->assertArrayHasKey( 'failed', $data ); + $this->assertEquals( 'validation', $data['failed'] ); + $this->assertCount( 2, $data['responses'] ); + $this->assertNull( $data['responses'][0] ); + $this->assertEquals( 400, $data['responses'][1]['status'] ); + $this->assertFalse( get_option( 'test_project' ) ); + } + + /** + * @ticket 50244 + */ + public function test_batch_v1_pre_validation_all_successful() { + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'methods' => 'POST', + 'callback' => static function ( $request ) { + return new WP_REST_Response( $request['project'] ); + }, + 'permission_callback' => '__return_true', + 'allow_batch' => array( 'v1' => true ), + 'args' => array( + 'project' => array( + 'type' => 'string', + 'enum' => array( 'gutenberg', 'WordPress' ), + ), + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'validation' => 'require-all-validate', + 'requests' => array( + array( + 'path' => '/test-ns/v1/test', + 'body' => array( + 'project' => 'gutenberg', + ), + ), + array( + 'path' => '/test-ns/v1/test', + 'body' => array( + 'project' => 'WordPress', + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 207, $response->get_status() ); + $this->assertArrayNotHasKey( 'failed', $data ); + $this->assertCount( 2, $data['responses'] ); + $this->assertEquals( 'gutenberg', $data['responses'][0]['body'] ); + $this->assertEquals( 'WordPress', $data['responses'][1]['body'] ); + } + + /** + * @ticket 50244 + */ + public function test_batch_v1() { + register_rest_route( + 'test-ns/v1', + '/test/(?P[\d+])', + array( + 'methods' => array( 'POST', 'DELETE' ), + 'callback' => function ( WP_REST_Request $request ) { + $this->assertEquals( 'DELETE', $request->get_method() ); + $this->assertEquals( '/test-ns/v1/test/5', $request->get_route() ); + $this->assertEquals( array( 'id' => '5' ), $request->get_url_params() ); + $this->assertEquals( array( 'query' => 'param' ), $request->get_query_params() ); + $this->assertEquals( array( 'project' => 'gutenberg' ), $request->get_body_params() ); + $this->assertEquals( array( 'my_header' => array( 'my-value' ) ), $request->get_headers() ); + + return new WP_REST_Response( 'test' ); + }, + 'permission_callback' => '__return_true', + 'allow_batch' => array( 'v1' => true ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'requests' => array( + array( + 'method' => 'DELETE', + 'path' => '/test-ns/v1/test/5?query=param', + 'headers' => array( + 'My-Header' => 'my-value', + ), + 'body' => array( + 'project' => 'gutenberg', + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 207, $response->get_status() ); + $this->assertEquals( 'test', $response->get_data()['responses'][0]['body'] ); + } + + /** + * @ticket 50244 + */ + public function test_batch_v1_partial_error() { + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'methods' => 'POST', + 'callback' => static function ( $request ) { + $project = $request['project']; + update_option( 'test_project', $project ); + + return new WP_REST_Response( $project ); + }, + 'permission_callback' => '__return_true', + 'allow_batch' => array( 'v1' => true ), + 'args' => array( + 'project' => array( + 'type' => 'string', + 'enum' => array( 'gutenberg', 'WordPress' ), + ), + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'requests' => array( + array( + 'path' => '/test-ns/v1/test', + 'body' => array( + 'project' => 'gutenberg', + ), + ), + array( + 'path' => '/test-ns/v1/test', + 'body' => array( + 'project' => 'buddypress', + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 207, $response->get_status() ); + $this->assertArrayNotHasKey( 'failed', $data ); + $this->assertCount( 2, $data['responses'] ); + $this->assertEquals( 'gutenberg', $data['responses'][0]['body'] ); + $this->assertEquals( 400, $data['responses'][1]['status'] ); + $this->assertEquals( 'gutenberg', get_option( 'test_project' ) ); + } + + + /** + * @ticket 50244 + */ + public function test_batch_v1_max_requests() { + add_filter( + 'rest_get_max_batch_size', + static function() { + return 5; + } + ); + + $GLOBALS['wp_rest_server'] = null; + add_filter( 'wp_rest_server_class', array( $this, 'filter_wp_rest_server_class' ) ); + $GLOBALS['wp_rest_server'] = rest_get_server(); + + register_rest_route( + 'test-ns/v1', + '/test/(?P[\d+])', + array( + 'methods' => array( 'POST', 'DELETE' ), + 'callback' => function ( WP_REST_Request $request ) { + return new WP_REST_Response( 'test' ); + }, + 'permission_callback' => '__return_true', + 'allow_batch' => array( 'v1' => true ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/batch/v1' ); + $request->set_body_params( + array( + 'requests' => array_fill( 0, 6, array( 'path' => '/test-ns/v1/test/5' ) ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + } + public function _validate_as_integer_123( $value, $request, $key ) { if ( ! is_int( $value ) ) { return new WP_Error( 'some-error', 'This is not valid!' ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index ccd13d4a46..3bc4c811f4 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -41,6 +41,78 @@ mockedApiResponse.Schema = { "self": "http://example.org/index.php?rest_route=/" } }, + "/batch/v1": { + "namespace": "", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "validation": { + "required": false, + "default": "normal", + "enum": [ + "require-all-validate", + "normal" + ], + "type": "string" + }, + "requests": { + "required": true, + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "default": "POST" + }, + "path": { + "type": "string", + "required": true + }, + "body": { + "type": "object", + "properties": [], + "additionalProperties": true + }, + "headers": { + "type": "object", + "properties": [], + "additionalProperties": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/batch/v1" + } + ] + } + }, "/oembed/1.0": { "namespace": "oembed/1.0", "methods": [