REST API: Introduce support for batching API requests.

A new route is introduced, `batch/v1`, that accepts a list of API requests to run. Each request runs in sequence, and the responses are returned in the order they've been received.

Optionally, the `require-all-validate` validation mode can be used to first validate each request's parameters and only proceed with processing if each request validates successfully.

By default, the batch size is limited to 25 requests. This can be controlled using the `rest_get_max_batch_size` filter. Clients are strongly encouraged to discover the maximum batch size supported by the server by making an OPTIONS request to the `batch/v1` endpoint and inspecting the described arguments.

Additionally, the two new methods, `match_request_to_handler` and `respond_to_request` introduced in [48947] now have a `protected` visibility as we don't want to expose the inner workings of the `WP_REST_Server::dispatch` API.

Batching is not currently supported for GET requests.

Fixes #50244.
Props andraganescu, zieladam, TimothyBlynJacobs.


git-svn-id: https://develop.svn.wordpress.org/trunk@49252 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Timothy Jacobs 2020-10-20 19:08:48 +00:00
parent bf73097310
commit 9defd1fabc
3 changed files with 606 additions and 3 deletions

View File

@ -94,7 +94,7 @@ class WP_REST_Server {
public function __construct() { public function __construct() {
$this->endpoints = array( $this->endpoints = array(
// Meta endpoints. // Meta endpoints.
'/' => array( '/' => array(
'callback' => array( $this, 'get_index' ), 'callback' => array( $this, 'get_index' ),
'methods' => 'GET', 'methods' => 'GET',
'args' => array( '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. * @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. * @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(); $method = $request->get_method();
$path = $request->get_route(); $path = $request->get_route();
@ -1058,7 +1103,7 @@ class WP_REST_Server {
* *
* @return WP_REST_Response * @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. * Filters the response before executing any REST API callbacks.
* *
@ -1396,6 +1441,178 @@ class WP_REST_Server {
return $data; 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. * Sends an HTTP status code.
* *

View File

@ -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() ); $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<id>[\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<id>[\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 ) { public function _validate_as_integer_123( $value, $request, $key ) {
if ( ! is_int( $value ) ) { if ( ! is_int( $value ) ) {
return new WP_Error( 'some-error', 'This is not valid!' ); return new WP_Error( 'some-error', 'This is not valid!' );

View File

@ -41,6 +41,78 @@ mockedApiResponse.Schema = {
"self": "http://example.org/index.php?rest_route=/" "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": { "/oembed/1.0": {
"namespace": "oembed/1.0", "namespace": "oembed/1.0",
"methods": [ "methods": [