diff --git a/editor/components/url-input/index.js b/editor/components/url-input/index.js index 8f5887bca3cd18..71c3778399e077 100644 --- a/editor/components/url-input/index.js +++ b/editor/components/url-input/index.js @@ -70,10 +70,10 @@ class UrlInput extends Component { loading: true, } ); this.suggestionsRequest = apiRequest( { - path: `/wp/v2/posts?${ stringify( { + path: `/gutenberg/v1/search?${ stringify( { search: value, per_page: 20, - orderby: 'relevance', + type_exclude: 'attachment', } ) }`, } ); @@ -227,7 +227,7 @@ class UrlInput extends Component { onClick={ () => this.selectLink( post.link ) } aria-selected={ index === selectedSuggestion } > - { decodeEntities( post.title.rendered ) || __( '(no title)' ) } + { decodeEntities( post.title ) || __( '(no title)' ) } ) ) } diff --git a/lib/class-wp-rest-search-controller.php b/lib/class-wp-rest-search-controller.php new file mode 100644 index 00000000000000..bbfeaa11e0c321 --- /dev/null +++ b/lib/class-wp-rest-search-controller.php @@ -0,0 +1,473 @@ +namespace = 'gutenberg/v1'; + $this->rest_base = 'search'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 3.0.0 + * + * @see register_rest_route() + */ + 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_permission_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to search content. + * + * @since 3.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has search access, WP_Error object otherwise. + */ + public function get_items_permission_check( $request ) { + + return true; + } + + /** + * Retrieves a collection of search results. + * + * @since 3.0.0 + * + * @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 ) { + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + $query_args = array( + 'post_status' => array_keys( $this->get_allowed_post_stati() ), + 'post_type' => $request['type'], + 'ignore_sticky_posts' => true, + ); + + // If a search term is given, order by relevance. + if ( ! empty( $request['search'] ) ) { + $query_args['orderby'] = 'relevance'; + $query_args['order'] = 'ASC'; + } + + // Transform 'any' into actual post type list. + if ( in_array( 'any', $request['type'], true ) ) { + $query_args['post_type'] = array_keys( $this->get_allowed_post_types() ); + } + + // Ensure to exclude post types as necessary. + if ( ! empty( $request['type_exclude'] ) ) { + $query_args['post_type'] = array_diff( $query_args['post_type'], $request['type_exclude'] ); + } + + /* + * This array defines mappings between public API query parameters whose + * values are accepted as-passed, and their internal WP_Query parameter + * name equivalents (some are the same). Only values which are also + * present in $registered will be set. + */ + $parameter_mappings = array( + 'page' => 'paged', + 'search' => 's', + ); + + /* + * For each known parameter which is both registered and present in the request, + * set the parameter's value on the $query_args. + */ + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $query_args[ $wp_param ] = $request[ $api_param ]; + } + } + + // Ensure our per_page parameter overrides any provided posts_per_page filter. + if ( isset( $registered['per_page'] ) ) { + $query_args['posts_per_page'] = $request['per_page']; + } + + $posts_query = new WP_Query(); + $query_result = $posts_query->query( $query_args ); + + $posts = array(); + + foreach ( $query_result as $post ) { + if ( ! $this->check_read_permission( $post ) ) { + continue; + } + + $data = $this->prepare_item_for_response( $post, $request ); + $posts[] = $this->prepare_response_for_collection( $data ); + } + + $page = (int) $query_args['paged']; + $total_posts = $posts_query->found_posts; + + if ( $total_posts < 1 ) { + // Out-of-bounds, run the query again without LIMIT for total count. + unset( $query_args['paged'] ); + + $count_query = new WP_Query(); + $count_query->query( $query_args ); + $total_posts = $count_query->found_posts; + } + + $max_pages = ceil( $total_posts / (int) $posts_query->query_vars['posts_per_page'] ); + + if ( $page > $max_pages && $total_posts > 0 ) { + return new WP_Error( 'rest_post_invalid_page_number', __( 'The page number requested is larger than the number of pages available.', 'gutenberg' ), array( 'status' => 400 ) ); + } + + $response = rest_ensure_response( $posts ); + + $response->header( 'X-WP-Total', (int) $total_posts ); + $response->header( 'X-WP-TotalPages', (int) $max_pages ); + + $request_params = $request->get_query_params(); + $base = add_query_arg( $request_params, rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + + if ( $page > 1 ) { + $prev_page = $page - 1; + + if ( $prev_page > $max_pages ) { + $prev_page = $max_pages; + } + + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + if ( $max_pages > $page ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Checks if a post can be read. + * + * Correctly handles posts with the inherit status. + * + * @since 3.0.0 + * + * @param object $post Post object. + * @return bool Whether the post can be read. + */ + public function check_read_permission( $post ) { + + // Is the post readable? + if ( 'publish' === $post->post_status ) { + return true; + } + + $post_type = get_post_type_object( $post->post_type ); + if ( current_user_can( $post_type->cap->read_post, $post->ID ) ) { + return true; + } + + // Can we read the parent if we're inheriting? + if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { + $parent = get_post( $post->post_parent ); + if ( $parent ) { + return $this->check_read_permission( $parent ); + } + } + + /* + * If there isn't a parent, but the status is set to inherit, assume + * it's published (as per get_post_status()). + */ + if ( 'inherit' === $post->post_status ) { + return true; + } + + return false; + } + + /** + * Prepares a single search result for response. + * + * @since 3.0.0 + * + * @param WP_Post $post Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $post, $request ) { + + $GLOBALS['post'] = $post; + + setup_postdata( $post ); + + if ( method_exists( $this, 'get_fields_for_response' ) ) { + $fields = $this->get_fields_for_response( $request ); + } else { + $schema = $this->get_item_schema(); + $fields = array_keys( $schema['properties'] ); + } + + // Base fields for every post. + $data = array(); + + if ( in_array( 'id', $fields, true ) ) { + $data['id'] = $post->ID; + } + + if ( in_array( 'type', $fields, true ) ) { + $data['type'] = $post->post_type; + } + + if ( in_array( 'link', $fields, true ) ) { + $data['link'] = get_permalink( $post->ID ); + } + + if ( in_array( 'title', $fields, true ) ) { + if ( post_type_supports( $post->post_type, 'title' ) ) { + add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); + + $data['title'] = get_the_title( $post->ID ); + + remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); + } else { + $data['title'] = ''; + } + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = rest_ensure_response( $data ); + + $response->add_links( $this->prepare_links( $post ) ); + + return $response; + } + + /** + * Overwrites the default protected title format. + * + * By default, WordPress will show password protected posts with a title of + * "Protected: %s". As the REST API communicates the protected status of a post + * in a machine readable format, we remove the "Protected: " prefix. + * + * @since 3.0.0 + * + * @return string Protected title format. + */ + public function protected_title_format() { + return '%s'; + } + + /** + * Retrieves the item schema, conforming to JSON Schema. + * + * @since 3.0.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'search-result', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the object.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'embed' ), + 'readonly' => true, + ), + 'link' => array( + 'description' => __( 'URL to the object.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'embed' ), + 'readonly' => true, + ), + 'title' => array( + 'description' => __( 'The title for the object.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'embed' ), + 'readonly' => true, + ), + 'type' => array( + 'description' => __( 'Type of the object.', 'gutenberg' ), + 'type' => 'string', + 'enum' => array_keys( $this->get_allowed_post_types() ), + 'context' => array( 'view', 'embed' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for the search results collection. + * + * @since 3.0.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'view'; + + $query_params['type'] = array( + 'default' => 'any', + 'description' => __( 'Limit search results to content of one or more types.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( + 'enum' => array_merge( array_keys( $this->get_allowed_post_types() ), array( 'any' ) ), + 'type' => 'string', + ), + ); + + $query_params['type_exclude'] = array( + 'default' => array(), + 'description' => __( 'Ensure search results exclude content of one or more specific types.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( + 'enum' => array_keys( $this->get_allowed_post_types() ), + 'type' => 'string', + ), + ); + + return $query_params; + } + + /** + * Prepares links for the request. + * + * @since 3.0.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + + $links = array(); + + $item_route = $this->detect_rest_item_route( $post ); + if ( ! empty( $item_route ) ) { + $links['self'] = array( + 'href' => rest_url( $item_route ), + 'embeddable' => true, + ); + } + + $links['collection'] = array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ); + + $links['about'] = array( + 'href' => rest_url( 'wp/v2/types/' . $post->post_type ), + ); + + return $links; + } + + /** + * Attempts to detect the route to access a single item. + * + * @since 3.0.0 + * + * @param WP_Post $post Post object. + * @return string REST route relative to the REST base URI, or empty string if not found. + */ + protected function detect_rest_item_route( $post ) { + $post_type = get_post_type_object( $post->post_type ); + if ( ! $post_type ) { + return ''; + } + + // It's currently impossible to detect the REST URL from a custom controller. + if ( ! empty( $post_type->rest_controller_class ) && 'WP_REST_Posts_Controller' !== $post_type->rest_controller_class ) { + return ''; + } + + $namespace = 'wp/v2'; + $rest_base = ! empty( $post_type->rest_base ) ? $post_type->rest_base : $post_type->name; + + return sprintf( '%s/%s/%d', $namespace, $rest_base, $post->ID ); + } + + /** + * Gets the post statuses allowed for search. + * + * @since 3.0.0 + * + * @return array List of post status objects, keyed by their name. + */ + protected function get_allowed_post_stati() { + + $post_stati = get_post_stati( array( + 'public' => true, + 'internal' => false, + ), 'objects' ); + + $post_stati['inherit'] = get_post_status_object( 'inherit' ); + + return $post_stati; + } + + /** + * Gets the post types allowed for search. + * + * @since 3.0.0 + * + * @return array List of post type objects, keyed by their name. + */ + protected function get_allowed_post_types() { + + return get_post_types( array( + 'public' => true, + 'show_in_rest' => true, + ), 'objects' ); + } +} diff --git a/lib/load.php b/lib/load.php index f1a8b09c66ce80..24854cd50399ad 100644 --- a/lib/load.php +++ b/lib/load.php @@ -15,6 +15,7 @@ require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php'; require dirname( __FILE__ ) . '/class-wp-rest-autosaves-controller.php'; require dirname( __FILE__ ) . '/class-wp-rest-block-renderer-controller.php'; + require dirname( __FILE__ ) . '/class-wp-rest-search-controller.php'; require dirname( __FILE__ ) . '/rest-api.php'; } diff --git a/lib/rest-api.php b/lib/rest-api.php index 2c691f954e184b..61a7b33a18707c 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -32,6 +32,9 @@ function gutenberg_register_rest_routes() { $autosaves_controller->register_routes(); } } + + $controller = new WP_REST_Search_Controller(); + $controller->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_rest_routes' ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 581632857259cb..fa324dd7bc4dc0 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -22,6 +22,7 @@ lib/class-wp-rest-block-renderer-controller.php + lib/class-wp-rest-search-controller.php diff --git a/phpunit/class-rest-search-controller-test.php b/phpunit/class-rest-search-controller-test.php new file mode 100644 index 00000000000000..b6a7ffbc572767 --- /dev/null +++ b/phpunit/class-rest-search-controller-test.php @@ -0,0 +1,334 @@ +post->create_many( 4, array( + 'post_title' => 'my-footitle', + 'post_type' => 'post', + ) ); + + self::$my_title_page_ids = $factory->post->create_many( 4, array( + 'post_title' => 'my-footitle', + 'post_type' => 'page', + ) ); + + self::$my_title_attachment_ids = $factory->attachment->create_many( 4, array( + 'post_title' => 'my-footitle', + 'post_type' => 'attachment', + ) ); + + self::$my_content_post_ids = $factory->post->create_many( 6, array( + 'post_content' => 'my-foocontent', + ) ); + } + + /** + * Delete our fake data after our tests run. + */ + public static function wpTearDownAfterClass() { + $post_ids = array_merge( + self::$my_title_post_ids, + self::$my_title_page_ids, + self::$my_title_attachment_ids, + self::$my_content_post_ids + ); + + foreach ( $post_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + } + + /** + * Check that our routes get set up properly. + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/gutenberg/v1/search', $routes ); + $this->assertCount( 1, $routes['/gutenberg/v1/search'] ); + } + + /** + * Check the context parameter. + */ + public function test_context_param() { + $response = $this->do_request_with_params( array(), 'OPTIONS' ); + $data = $response->get_data(); + + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEquals( array( 'view', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * Search through all content. + */ + public function test_get_items() { + $response = $this->do_request_with_params( array( + 'per_page' => 100, + ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( + array_merge( + self::$my_title_post_ids, + self::$my_title_page_ids, + self::$my_title_attachment_ids, + self::$my_content_post_ids + ), + wp_list_pluck( $response->get_data(), 'id' ) + ); + } + + /** + * Search through our posts. + */ + public function test_get_items_search_posts() { + $response = $this->do_request_with_params( array( + 'per_page' => 100, + 'type' => 'post', + ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( + array_merge( + self::$my_title_post_ids, + self::$my_content_post_ids + ), + wp_list_pluck( $response->get_data(), 'id' ) + ); + } + + /** + * Search through our posts and pages. + */ + public function test_get_items_search_posts_and_pages() { + $response = $this->do_request_with_params( array( + 'per_page' => 100, + 'type' => 'post,page', + ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( + array_merge( + self::$my_title_post_ids, + self::$my_title_page_ids, + self::$my_content_post_ids + ), + wp_list_pluck( $response->get_data(), 'id' ) + ); + } + + /** + * Search through all content but attachments. + */ + public function test_get_items_search_exclude_attachments() { + $response = $this->do_request_with_params( array( + 'per_page' => 100, + 'type_exclude' => 'attachment', + ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( + array_merge( + self::$my_title_post_ids, + self::$my_title_page_ids, + self::$my_content_post_ids + ), + wp_list_pluck( $response->get_data(), 'id' ) + ); + } + + /** + * Search through all that matches a 'footitle' search. + */ + public function test_get_items_search_for_footitle() { + $response = $this->do_request_with_params( array( + 'per_page' => 100, + 'search' => 'footitle', + ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( + array_merge( + self::$my_title_post_ids, + self::$my_title_page_ids, + self::$my_title_attachment_ids + ), + wp_list_pluck( $response->get_data(), 'id' ) + ); + } + + /** + * Search through all that matches a 'foocontent' search. + */ + public function test_get_items_search_for_foocontent() { + $response = $this->do_request_with_params( array( + 'per_page' => 100, + 'search' => 'foocontent', + ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEqualSets( + self::$my_content_post_ids, + wp_list_pluck( $response->get_data(), 'id' ) + ); + } + + /** + * Test retrieving a single item isn't possible. + */ + public function test_get_item() { + /** The search controller does not allow getting individual item content */ + $request = new WP_REST_Request( 'GET', '/gutenberg/v1/search' . self::$my_title_post_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test creating an item isn't possible. + */ + public function test_create_item() { + /** The search controller does not allow creating content */ + $request = new WP_REST_Request( 'POST', '/gutenberg/v1/search' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test updating an item isn't possible. + */ + public function test_update_item() { + /** The search controller does not allow upading content */ + $request = new WP_REST_Request( 'POST', '/gutenberg/v1/search' . self::$my_title_post_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test deleting an item isn't possible. + */ + public function test_delete_item() { + /** The search controller does not allow deleting content */ + $request = new WP_REST_Request( 'DELETE', '/gutenberg/v1/search' . self::$my_title_post_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test preparing the data contains the correct fields. + */ + public function test_prepare_item() { + $response = $this->do_request_with_params(); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( array( + 'id', + 'type', + 'link', + 'title', + '_links', + ), array_keys( $data[0] ) ); + } + + /** + * Test preparing the data with limited fields contains the correct fields. + */ + public function test_prepare_item_limit_fields() { + if ( ! method_exists( 'WP_REST_Controller', 'get_fields_for_response' ) ) { + $this->markTestSkipped( 'Limiting fields requires the WP_REST_Controller::get_fields_for_response() method.' ); + } + + $response = $this->do_request_with_params( array( + '_fields' => 'id,title', + ) ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( array( + 'id', + 'title', + '_links', + ), array_keys( $data[0] ) ); + } + + /** + * Tests the item schema is correct. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/gutenberg/v1/search' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + $this->assertArrayHasKey( 'type', $properties ); + } + + /** + * Tests that non-public post types are not allowed. + */ + public function test_non_public_type() { + $response = $this->do_request_with_params( array( + 'type' => 'post,nav_menu_item', + ) ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Perform a REST request to our search endpoint with given parameters. + */ + private function do_request_with_params( $params = array(), $method = 'GET' ) { + $request = new WP_REST_Request( $method, '/gutenberg/v1/search' ); + + foreach ( $params as $param => $value ) { + $request->set_param( $param, $value ); + } + + return rest_get_server()->dispatch( $request ); + } +}