Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

REST API: Introduce batch controller #25096

Merged
merged 10 commits into from
Oct 7, 2020
302 changes: 302 additions & 0 deletions lib/class-wp-rest-batch-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
<?php
/**
* REST API: WP_REST_Batch_Controller class
*
* @package Gutenberg
* @subpackage REST_API
*/

/**
* Core class used to perform abtch requests.
*
* @see WP_REST_Controller
*/
class WP_REST_Batch_Controller {

/**
* Registers the REST API route.
*
* @since 9.2.0
*/
public function register_routes() {
register_rest_route(
'__experimental',
'batch',
array(
'callback' => array( $this, 'serve_batch_request' ),
'permission_callback' => '__return_true',
'methods' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ),
'args' => array(
'validation' => array(
'type' => 'string',
'enum' => array( 'require-all-validate', 'normal' ),
'default' => 'normal',
),
'requests' => array(
'required' => true,
'type' => 'array',
'maxItems' => 25,
draganescu marked this conversation as resolved.
Show resolved Hide resolved
'items' => array(
'type' => 'object',
'properties' => array(
'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',
),
),
),
),
),
),
),
)
);
}

/**
* Serves the batch request.
*
* @since 9.2.0
*
* @param WP_REST_Request $batch_request The batch request object.
* @return WP_REST_Response
*/
public function serve_batch_request( 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.', 'gutenberg' ), array( 'status' => 400 ) );

continue;
}

$single_request = new WP_REST_Request( $batch_request->get_method(), $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;
draganescu marked this conversation as resolved.
Show resolved Hide resolved
}

if ( ! method_exists( rest_get_server(), 'match_request_to_handler' ) ) {
return $this->polyfill_batching( $requests );
}

$matches = array();
$validation = array();
$has_error = false;

foreach ( $requests as $single_request ) {
$match = rest_get_server()->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 {
$allow_batch = ! empty( rest_get_server()->get_route_options( $route )['allow_batch'] );
}

if ( ! $allow_batch ) {
$error = new WP_Error(
'rest_batch_not_allowed',
__( 'The requested route does not support batch requests.', 'gutenberg' ),
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[] = rest_get_server()->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, rest_get_server(), $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', 'gutenberg' ),
array( 'status' => 500 )
);
}

$result = rest_get_server()->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 ), rest_get_server(), $single_request );

$responses[] = rest_get_server()->envelope_response( $result, false )->get_data();
}

return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS );
}

/**
* Polyfills a simple form of batching for compatibility for non-trunk installs.
*
* @since 9.2.0
*
* @param WP_REST_Request[] $requests The list of requests to perform.
* @return WP_REST_Response The response object.
*/
protected function polyfill_batching( $requests ) {
$responses = array();

foreach ( $requests as $request ) {
if ( 0 !== strpos( $request->get_route(), '/__experimental' ) ) {
draganescu marked this conversation as resolved.
Show resolved Hide resolved
$error = new WP_Error(
'rest_batch_not_allowed',
__( 'The requested route does not support batch requests.', 'gutenberg' ),
array( 'status' => 400 )
);
$responses[] = rest_get_server()->envelope_response( $this->error_to_response( $error ), false )->get_data();
continue;
}

$result = rest_get_server()->dispatch( $request );
/** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
$result = apply_filters( 'rest_post_dispatch', rest_ensure_response( $result ), rest_get_server(), $request );

$responses[] = rest_get_server()->envelope_response( $result, false )->get_data();
}

return new WP_REST_Response( array( 'responses' => $responses ), WP_Http::MULTI_STATUS );
}

/**
* Converts an error to a response object.
*
* @see WP_REST_Server::error_to_response() This is a temporary copy of that method due to visibility.
*
* @since 9.2.0
*
* @param WP_Error $error WP_Error instance.
* @return WP_REST_Response List of associative arrays with code and message keys.
*/
protected function error_to_response( $error ) {
$error_data = $error->get_error_data();

if ( is_array( $error_data ) && isset( $error_data['status'] ) ) {
$status = $error_data['status'];
} else {
$status = 500;
}

$errors = array();

foreach ( (array) $error->errors as $code => $messages ) {
foreach ( (array) $messages as $message ) {
$errors[] = array(
'code' => $code,
'message' => $message,
'data' => $error->get_error_data( $code ),
);
}
}

$data = $errors[0];
if ( count( $errors ) > 1 ) {
// Remove the primary error.
array_shift( $errors );
$data['additional_errors'] = $errors;
}

$response = new WP_REST_Response( $data, $status );

return $response;
}
}
77 changes: 77 additions & 0 deletions lib/class-wp-rest-menu-items-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,83 @@ public function __construct( $post_type ) {
$this->namespace = '__experimental';
}

/**
* Overrides the route registration to support "allow_batch".
*
* @since 9.2.0
*/
public function register_routes() {
Copy link
Contributor

@adamziel adamziel Sep 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why this code had to be copied and pasted, still I would love to get rid of it (maybe not in this PR). Maybe by updating WP_REST_Posts_Controller::register_routes() to accept an optional $allow_batch=false argument? We could also add a filter to register_rest_route.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah its really not great. My current thinking is we'll just make all posts controllers opt in to batching when we do the Core merge.

register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'create_item_permissions_check' ),
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
),
'allow_batch' => true,
'schema' => array( $this, 'get_public_item_schema' ),
)
);

$schema = $this->get_item_schema();
$get_item_args = array(
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
);
if ( isset( $schema['properties']['password'] ) ) {
$get_item_args['password'] = array(
'description' => __( 'The password for the post if it is password protected.', 'gutenberg' ),
'type' => 'string',
);
}
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
array(
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the object.', 'gutenberg' ),
'type' => 'integer',
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
'args' => $get_item_args,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'delete_item_permissions_check' ),
'args' => array(
'force' => array(
'type' => 'boolean',
'default' => false,
'description' => __( 'Whether to bypass Trash and force deletion.', 'gutenberg' ),
),
),
),
'allow_batch' => true,
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}

/**
* Get the post, if the ID is valid.
*
Expand Down
3 changes: 3 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ function gutenberg_is_experiment_enabled( $name ) {
if ( ! class_exists( 'WP_REST_Term_Search_Handler' ) ) {
require_once dirname( __FILE__ ) . '/class-wp-rest-term-search-handler.php';
}
if ( ! class_exists( 'WP_REST_Batch_Controller' ) ) {
require_once dirname( __FILE__ ) . '/class-wp-rest-batch-controller.php';
}
/**
* End: Include for phase 2
*/
Expand Down
Loading