diff --git a/lib/class-wp-rest-batch-controller.php b/lib/class-wp-rest-batch-controller.php new file mode 100644 index 00000000000000..b07262f9fc2354 --- /dev/null +++ b/lib/class-wp-rest-batch-controller.php @@ -0,0 +1,302 @@ + 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, + '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; + } + + 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' ) ) { + $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; + } +} diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index f6cf192a80722c..6bf3ac474f552e 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -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() { + 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[\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. * diff --git a/lib/load.php b/lib/load.php index 540539f56ba59a..e117ac2425c03c 100644 --- a/lib/load.php +++ b/lib/load.php @@ -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 */ diff --git a/lib/rest-api.php b/lib/rest-api.php index e10f52805bcb43..b4bca6d9af37d2 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -197,6 +197,15 @@ function gutenberg_register_sidebars_endpoint() { } add_action( 'rest_api_init', 'gutenberg_register_sidebars_endpoint' ); +/** + * Registers the Batch REST API routes. + */ +function gutenberg_register_batch_endpoint() { + $batch = new WP_REST_Batch_Controller(); + $batch->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_batch_endpoint' ); + /** * Hook in to the nav menu item post type and enable a post type rest endpoint. * diff --git a/phpunit/class-rest-batch-controller-test.php b/phpunit/class-rest-batch-controller-test.php new file mode 100644 index 00000000000000..af1664034c859e --- /dev/null +++ b/phpunit/class-rest-batch-controller-test.php @@ -0,0 +1,232 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + } + + /** + * Delete test data after our tests run. + * + * @since 9.2.0 + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$administrator_id ); + } + + /** + * @since 9.2.0 + */ + public function setUp() { + parent::setUp(); + + $this->tag_id = self::factory()->tag->create(); + $this->menu_id = wp_create_nav_menu( rand_str() ); + $this->menu_item_id = wp_update_nav_menu_item( + $this->menu_id, + 0, + array( + 'menu-item-type' => 'taxonomy', + 'menu-item-object' => 'post_tag', + 'menu-item-object-id' => $this->tag_id, + 'menu-item-status' => 'publish', + ) + ); + + /** @var WP_REST_Server $wp_rest_server */ + global $wp_rest_server; + $wp_rest_server = new Spy_REST_Server; + do_action( 'rest_api_init', $wp_rest_server ); + } + + /** + * @since 9.2.0 + */ + public function tearDown() { + parent::tearDown(); + /** @var WP_REST_Server $wp_rest_server */ + global $wp_rest_server; + $wp_rest_server = null; + } + + /** + * @ticket 50244 + */ + public function test_batch_requires_allow_batch_opt_in() { + register_rest_route( + 'test-ns/v1', + '/test', + array( + 'methods' => 'POST', + 'callback' => static function () { + return new WP_REST_Response( 'data' ); + }, + 'permission_callback' => '__return_true', + ) + ); + + $request = new WP_REST_Request( 'POST', '/__experimental/batch' ); + $request->set_body_params( + array( + 'requests' => array( + array( + 'path' => '/test-ns/v1/test', + ), + ), + ) + ); + + $response = rest_do_request( $request ); + + $this->assertEquals( 207, $response->get_status() ); + $this->assertEquals( 'rest_batch_not_allowed', $response->get_data()['responses'][0]['body']['code'] ); + } + + /** + * @ticket 50244 + */ + public function test_batch_pre_validation() { + wp_set_current_user( self::$administrator_id ); + + $request = new WP_REST_Request( 'POST', '/__experimental/batch' ); + $request->set_body_params( + array( + 'validation' => 'require-all-validate', + 'requests' => array( + array( + 'path' => '/__experimental/menu-items', + 'body' => array( + 'title' => 'Hello World', + 'content' => 'From the moon.', + 'type' => 'custom', + 'url' => '#', + 'menus' => $this->menu_id, + ), + ), + array( + 'path' => '/__experimental/menu-items', + 'body' => array( + 'title' => 'Hello Moon', + 'content' => 'From the world.', + 'status' => 'garbage', + 'type' => 'custom', + 'url' => '#', + 'menus' => $this->menu_id, + ), + ), + ), + ) + ); + + $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'] ); + } + + /** + * @ticket 50244 + */ + public function test_batch_create() { + wp_set_current_user( self::$administrator_id ); + + $request = new WP_REST_Request( 'POST', '/__experimental/batch' ); + $request->set_body_params( + array( + 'requests' => array( + array( + 'path' => '/__experimental/menu-items', + 'body' => array( + 'title' => 'Hello World', + 'content' => 'From the moon.', + 'type' => 'custom', + 'url' => '#', + 'menus' => $this->menu_id, + ), + ), + array( + 'path' => '/__experimental/menu-items', + 'body' => array( + 'title' => 'Hello Moon', + 'status' => 'draft', + 'content' => 'From the world.', + 'type' => 'custom', + 'url' => '#', + 'menus' => $this->menu_id, + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 207, $response->get_status() ); + $this->assertArrayHasKey( 'responses', $data ); + $this->assertCount( 2, $data['responses'] ); + $this->assertEquals( 'Hello World', $data['responses'][0]['body']['title']['rendered'] ); + $this->assertEquals( 'Hello Moon', $data['responses'][1]['body']['title']['rendered'] ); + } +}