diff --git a/gutenberg.php b/gutenberg.php index 41c586181612c9..dfe8764b81b371 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -18,6 +18,7 @@ // Load API functions, register scripts and actions, etc. require_once dirname( __FILE__ ) . '/lib/class-wp-block-type.php'; require_once dirname( __FILE__ ) . '/lib/class-wp-block-type-registry.php'; + require_once dirname( __FILE__ ) . '/lib/class-wp-rest-reusable-blocks-controller.php'; require_once dirname( __FILE__ ) . '/lib/blocks.php'; require_once dirname( __FILE__ ) . '/lib/client-assets.php'; require_once dirname( __FILE__ ) . '/lib/compat.php'; diff --git a/lib/class-wp-rest-reusable-blocks-controller.php b/lib/class-wp-rest-reusable-blocks-controller.php new file mode 100644 index 00000000000000..534af993e1c20e --- /dev/null +++ b/lib/class-wp-rest-reusable-blocks-controller.php @@ -0,0 +1,353 @@ +namespace for the namespace keyword + $this->namespace = 'gutenberg/v1'; + $this->rest_base = 'reusable-blocks'; + } + + /** + * Registers the necessary REST API routes. + * + * @since 0.10.0 + * @access public + */ + public function register_routes() { + // @codingStandardsIgnoreLine - PHPCS mistakes $this->namespace for the namespace keyword + $namespace = $this->namespace; + + register_rest_route( $namespace, '/' . $this->rest_base, array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) ); + + register_rest_route( $namespace, '/' . $this->rest_base . '/(?P[\w-]+)', array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) ); + } + + /** + * Checks if a given request has access to read reusable blocks. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'gutenberg_reusable_block_cannot_read', __( 'Sorry, you are not allowed to read reusable blocks as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Retrieves a collection of reusable blocks. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $reusable_blocks = get_posts( array( + 'post_type' => 'gb_reusable_block', + ) ); + + $collection = array(); + + foreach ( $reusable_blocks as $reusable_block ) { + $response = $this->prepare_item_for_response( $reusable_block, $request ); + $collection[] = $this->prepare_response_for_collection( $response ); + } + + return rest_ensure_response( $collection ); + } + + /** + * Checks if a given request has access to read a reusable block. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'gutenberg_reusable_block_cannot_read', __( 'Sorry, you are not allowed to read reusable blocks as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Retrieves a single reusable block. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $uuid = $request['id']; + if ( ! $this->is_valid_uuid4( $uuid ) ) { + return new WP_Error( 'gutenberg_reusable_block_invalid_id', __( 'ID is not a valid UUID v4.', 'gutenberg' ), array( + 'status' => 404, + ) ); + } + + $reusable_block = $this->get_reusable_block( $uuid ); + if ( ! $reusable_block ) { + return new WP_Error( 'gutenberg_reusable_block_not_found', __( 'No reusable block with that ID found.', 'gutenberg' ), array( + 'status' => 404, + ) ); + } + + return $this->prepare_item_for_response( $reusable_block, $request ); + } + + /** + * Checks if a given request has access to update a reusable block. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'gutenberg_reusable_block_cannot_edit', __( 'Sorry, you are not allowed to edit reusable blocks as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Updates a single reusable block. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function update_item( $request ) { + $uuid = $request['id']; + if ( ! $this->is_valid_uuid4( $uuid ) ) { + return new WP_Error( 'gutenberg_reusable_block_invalid_id', __( 'ID is not a valid UUID v4.', 'gutenberg' ), array( + 'status' => 404, + ) ); + } + + $reusable_block = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $reusable_block ) ) { + return $reusable_block; + } + + // wp_insert_post will unslash its input, so we have to slash it first. + $post_id = wp_insert_post( wp_slash( (array) $reusable_block ), true ); + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $reusable_block = get_post( $post_id ); + + return $this->prepare_item_for_response( $reusable_block, $request ); + } + + /** + * Prepares a single reusable block for update. + * + * @since 0.10.0 + * @access protected + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Object suitable for passing to wp_insert_post, or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_reusable_block = new stdClass(); + + $existing_reusable_block = $this->get_reusable_block( $request['id'] ); + if ( $existing_reusable_block ) { + $prepared_reusable_block->ID = $existing_reusable_block->ID; + } + + $prepared_reusable_block->post_type = 'gb_reusable_block'; + $prepared_reusable_block->post_status = 'publish'; + + // ID. We already validated this in self::update_item(). + $prepared_reusable_block->post_name = $request['id']; + + // Name. + if ( isset( $request['name'] ) && is_string( $request['name'] ) ) { + $prepared_reusable_block->post_title = $request['name']; + } else { + return new WP_Error( 'gutenberg_reusable_block_invalid_field', __( 'Invalid reusable block name.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + // Content. + if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { + $prepared_reusable_block->post_content = $request['content']; + } else { + return new WP_Error( 'gutenberg_reusable_block_invalid_field', __( 'Invalid reusable block content.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + return $prepared_reusable_block; + } + + /** + * Prepares a single reusable block output for response. + * + * @since 0.10.0 + * @access protected + * + * @param WP_Post $reusable_block The reusable block. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $reusable_block, $request ) { + $data = array( + 'id' => $reusable_block->post_name, + 'name' => $reusable_block->post_title, + 'content' => $reusable_block->post_content, + ); + + return rest_ensure_response( $data ); + } + + /** + * Prepares a response for insertion into a collection. + * + * @since 0.10.0 + * @access public + * + * @param WP_REST_Response $response Response object. + * @return array|mixed Response data, ready for insertion into collection data. + */ + public function prepare_response_for_collection( $response ) { + return (array) $response->get_data(); + } + + /** + * Retrieves a reusable block's schema, conforming to JSON Schema. + * + * @since 0.10.0 + * @access public + * + * @return array Item schema data. + */ + public function get_item_schema() { + return array( + '$schema' => 'http://json-schema.org/schema#', + 'title' => 'reusable-block', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'UUID that identifies this reusable block.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Name that identifies this reusable block', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'required' => true, + ), + 'content' => array( + 'description' => __( 'The block\'s HTML content.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'required' => true, + ), + ), + ); + } + + /** + * Fetches a reusable block by its UUID ID. Reusable blocks are stored as posts with a custom post type. + * + * @since 0.10.0 + * @access private + * + * @param string $uuid A UUID string that uniquely identifies the reusable block. + * + * @return WP_Post|null The block (a WP_Post), or null if none was found. + */ + private function get_reusable_block( $uuid ) { + $reusable_blocks = get_posts( array( + 'post_type' => 'gb_reusable_block', + 'name' => $uuid, + ) ); + + return array_shift( $reusable_blocks ); + } + + /** + * Checks if the given value is a valid UUID v4 string. + * + * @since 0.10.0 + * @access private + * + * @param mixed $uuid The value to validate. + * @return bool Whether or not the string is a valid UUID v4 string. + */ + private function is_valid_uuid4( $uuid ) { + if ( ! is_string( $uuid ) ) { + return false; + } + + return (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid ); + } +} diff --git a/lib/register.php b/lib/register.php index 2d024ee3ec4581..5bae7ccbe26d36 100644 --- a/lib/register.php +++ b/lib/register.php @@ -300,3 +300,27 @@ function gutenberg_add_gutenberg_post_state( $post_states, $post ) { return $post_states; } add_filter( 'display_post_states', 'gutenberg_add_gutenberg_post_state', 10, 2 ); + +/** + * Registers custom post types required by the Gutenberg editor. + * + * @since 0.10.0 + */ +function gutenberg_register_post_types() { + register_post_type( 'gb_reusable_block', array( + 'public' => false, + ) ); +} +add_action( 'init', 'gutenberg_register_post_types' ); + +/** + * Registers the REST API routes needed by the Gutenberg editor. + * + * @since 0.10.0 + */ +function gutenberg_register_rest_routes() { + $controller = new WP_REST_Reusable_Blocks_Controller(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_rest_routes' ); + diff --git a/phpunit/class-rest-reusable-blocks-controller-test.php b/phpunit/class-rest-reusable-blocks-controller-test.php new file mode 100644 index 00000000000000..895cedcc2716f2 --- /dev/null +++ b/phpunit/class-rest-reusable-blocks-controller-test.php @@ -0,0 +1,285 @@ + 'gb_reusable_block', + 'post_status' => 'publish', + 'post_name' => '2d66a5c5-776c-43b1-98c7-49521cef8ea6', + 'post_title' => 'My cool block', + 'post_content' => '

Hello!

', + ) ); + + self::$editor_id = $factory->user->create( array( + 'role' => 'editor', + ) ); + self::$subscriber_id = $factory->user->create( array( + 'role' => 'subscriber', + ) ); + } + + /** + * Delete our fake data after our tests run. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$reusable_block_post_id ); + + self::delete_user( self::$editor_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * Check that our routes get set up properly. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + + $this->assertArrayHasKey( '/gutenberg/v1/reusable-blocks', $routes ); + $this->assertCount( 1, $routes['/gutenberg/v1/reusable-blocks'] ); + $this->assertArrayHasKey( '/gutenberg/v1/reusable-blocks/(?P[\w-]+)', $routes ); + $this->assertCount( 2, $routes['/gutenberg/v1/reusable-blocks/(?P[\w-]+)'] ); + } + + /** + * Check that we can GET a collection of reusable blocks. + */ + public function test_get_items() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/reusable-blocks' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( array( + array( + 'id' => '2d66a5c5-776c-43b1-98c7-49521cef8ea6', + 'name' => 'My cool block', + 'content' => '

Hello!

', + ), + ), $response->get_data() ); + } + + /** + * Check that users without permission can't GET a collection of reusable blocks. + */ + public function test_get_items_when_not_allowed() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/reusable-blocks' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 403, $response->get_status() ); + $this->assertEquals( 'gutenberg_reusable_block_cannot_read', $data['code'] ); + } + + /** + * Check that we can GET a single reusable block. + */ + public function test_get_item() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/reusable-blocks/2d66a5c5-776c-43b1-98c7-49521cef8ea6' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( array( + 'id' => '2d66a5c5-776c-43b1-98c7-49521cef8ea6', + 'name' => 'My cool block', + 'content' => '

Hello!

', + ), $response->get_data() ); + } + + /** + * Check that users without permission can't GET a single reusable block. + */ + public function test_get_item_when_not_allowed() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/reusable-blocks/2d66a5c5-776c-43b1-98c7-49521cef8ea6' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 403, $response->get_status() ); + $this->assertEquals( 'gutenberg_reusable_block_cannot_read', $data['code'] ); + } + + /** + * Check that invalid UUIDs 404. + */ + public function test_get_item_invalid_id() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/reusable-blocks/invalid-uuid' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 404, $response->get_status() ); + $this->assertEquals( 'gutenberg_reusable_block_invalid_id', $data['code'] ); + } + + /** + * Check that we get a 404 when we GET a non-existent reusable block. + */ + public function test_get_item_not_found() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/reusable-blocks/6e614ced-e80d-4e10-bd04-1e890b5f7f83' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 404, $response->get_status() ); + $this->assertEquals( 'gutenberg_reusable_block_not_found', $data['code'] ); + } + + /** + * Check that we can PUT a single reusable block. + */ + public function test_update_item() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', '/gutenberg/v1/reusable-blocks/75236553-f4ba-4f12-aa25-4ba402044bd5' ); + $request->set_body_params( array( + 'name' => 'Another cool block', + 'content' => '
An image
', + ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( array( + 'id' => '75236553-f4ba-4f12-aa25-4ba402044bd5', + 'name' => 'Another cool block', + 'content' => '
An image
', + ), $response->get_data() ); + } + + /** + * Check that users without permission can't PUT a single reusable block. + */ + public function test_update_item_when_not_allowed() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'PUT', '/gutenberg/v1/reusable-blocks/2d66a5c5-776c-43b1-98c7-49521cef8ea6' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 403, $response->get_status() ); + $this->assertEquals( 'gutenberg_reusable_block_cannot_edit', $data['code'] ); + } + + /** + * Test cases for test_update_item_with_invalid_fields(). + * + * @return array + */ + public function data_update_item_with_invalid_fields() { + return array( + array( + array(), + 'Invalid reusable block name.', + ), + array( + array( + 'name' => 42, + ), + 'Invalid reusable block name.', + ), + array( + array( + 'name' => 'My cool block', + ), + 'Invalid reusable block content.', + ), + array( + array( + 'name' => 'My cool block', + 'content' => 42, + ), + 'Invalid reusable block content.', + ), + ); + } + + /** + * Check that attributes are validated correctly when we PUT a single reusable block. + * + * @dataProvider data_update_item_with_invalid_fields + */ + public function test_update_item_with_invalid_fields( $body_params, $expected_message ) { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', '/gutenberg/v1/reusable-blocks/75236553-f4ba-4f12-aa25-4ba402044bd5' ); + $request->set_body_params( $body_params ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'gutenberg_reusable_block_invalid_field', $data['code'] ); + $this->assertEquals( $expected_message, $data['message'] ); + } + + /** + * Check that we have defined a JSON schema. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/gutenberg/v1/reusable-blocks' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertEquals( 3, count( $properties ) ); + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + } + + public function test_context_param() { + $this->markTestSkipped( 'Controller doesn\'t implement get_context_param().' ); + } + public function test_create_item() { + $this->markTestSkipped( 'Controller doesn\'t implement create_item().' ); + } + public function test_delete_item() { + $this->markTestSkipped( 'Controller doesn\'t implement delete_item().' ); + } + public function test_prepare_item() { + $this->markTestSkipped( 'Controller doesn\'t implement prepare_item().' ); + } +}