From 6cc0063ba6760fbe265116aa975ef0b979a0e8cc Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Thu, 2 Jul 2020 05:55:04 +0000 Subject: [PATCH] REST API: Link to the REST route for the currently queried resource. This allows for programatically determining the REST version of the current page. The links also aid human discovery of the REST API in general. Props dshanske, tfrommen, TimothyBlynJacobs. Fixes #49116. git-svn-id: https://develop.svn.wordpress.org/trunk@48273 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-taxonomy.php | 46 ++++++ src/wp-includes/rest-api.php | 142 +++++++++++++++++- tests/phpunit/tests/rest-api.php | 108 +++++++++++++ .../rest-api/rest-taxonomies-controller.php | 28 ++++ 4 files changed, 316 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/class-wp-taxonomy.php b/src/wp-includes/class-wp-taxonomy.php index 0ff90fb53e..ebc7b0406f 100644 --- a/src/wp-includes/class-wp-taxonomy.php +++ b/src/wp-includes/class-wp-taxonomy.php @@ -209,6 +209,16 @@ final class WP_Taxonomy { */ public $rest_controller_class; + /** + * The controller instance for this taxonomy's REST API endpoints. + * + * Lazily computed. Should be accessed using {@see WP_Taxonomy::get_rest_controller()}. + * + * @since 5.5.0 + * @var WP_REST_Controller $rest_controller + */ + public $rest_controller; + /** * Whether it is a built-in taxonomy. * @@ -452,4 +462,40 @@ final class WP_Taxonomy { public function remove_hooks() { remove_filter( 'wp_ajax_add-' . $this->name, '_wp_ajax_add_hierarchical_term' ); } + + /** + * Gets the REST API controller for this taxonomy. + * + * Will only instantiate the controller class once per request. + * + * @since 5.5.0 + * + * @return WP_REST_Controller|null The controller instance, or null if the taxonomy + * is set not to show in rest. + */ + public function get_rest_controller() { + if ( ! $this->show_in_rest ) { + return null; + } + + $class = $this->rest_controller_class ? $this->rest_controller_class : WP_REST_Terms_Controller::class; + + if ( ! class_exists( $class ) ) { + return null; + } + + if ( ! is_subclass_of( $class, WP_REST_Controller::class ) ) { + return null; + } + + if ( ! $this->rest_controller ) { + $this->rest_controller = new $class( $this->name ); + } + + if ( ! ( $this->rest_controller instanceof $class ) ) { + return null; + } + + return $this->rest_controller; + } } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index bb846cac26..67856cbcc9 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -234,13 +234,9 @@ function create_initial_rest_routes() { // Terms. foreach ( get_taxonomies( array( 'show_in_rest' => true ), 'object' ) as $taxonomy ) { - $class = ! empty( $taxonomy->rest_controller_class ) ? $taxonomy->rest_controller_class : 'WP_REST_Terms_Controller'; + $controller = $taxonomy->get_rest_controller(); - if ( ! class_exists( $class ) ) { - continue; - } - $controller = new $class( $taxonomy->name ); - if ( ! is_subclass_of( $controller, 'WP_REST_Controller' ) ) { + if ( ! $controller ) { continue; } @@ -873,7 +869,13 @@ function rest_output_link_wp_head() { return; } - echo "\n"; + printf( '', esc_url( $api_root ) ); + + $resource = rest_get_queried_resource_route(); + + if ( $resource ) { + printf( '', esc_url( rest_url( $resource ) ) ); + } } /** @@ -892,7 +894,13 @@ function rest_output_link_header() { return; } - header( 'Link: <' . esc_url_raw( $api_root ) . '>; rel="https://api.w.org/"', false ); + header( sprintf( 'Link: <%s>; rel="https://api.w.org/"', esc_url_raw( $api_root ) ), false ); + + $resource = rest_get_queried_resource_route(); + + if ( $resource ) { + header( sprintf( 'Link: <%s>; rel="alternate"; type="application/json"', esc_url_raw( rest_url( $resource ) ) ), false ); + } } /** @@ -1823,3 +1831,121 @@ function rest_default_additional_properties_to_false( $schema ) { return $schema; } + +/** + * Gets the REST API route for a post. + * + * @since 5.5.0 + * + * @param int|WP_Post $post Post ID or post object. + * @return string The route path with a leading slash for the given post, or an empty string if there is not a route. + */ +function rest_get_route_for_post( $post ) { + $post = get_post( $post ); + + if ( ! $post instanceof WP_Post ) { + return ''; + } + + $post_type = get_post_type_object( $post->post_type ); + if ( ! $post_type ) { + return ''; + } + + $controller = $post_type->get_rest_controller(); + if ( ! $controller ) { + return ''; + } + + $route = ''; + + // The only two controllers that we can detect are the Attachments and Posts controllers. + if ( in_array( get_class( $controller ), array( 'WP_REST_Attachments_Controller', 'WP_REST_Posts_Controller' ), true ) ) { + $namespace = 'wp/v2'; + $rest_base = ! empty( $post_type->rest_base ) ? $post_type->rest_base : $post_type->name; + $route = sprintf( '/%s/%s/%d', $namespace, $rest_base, $post->ID ); + } + + /** + * Filters the REST API route for a post. + * + * @since 5.5.0 + * + * @param string $route The route path. + * @param WP_Post $post The post object. + */ + return apply_filters( 'rest_route_for_post', $route, $post ); +} + +/** + * Gets the REST API route for a term. + * + * @since 5.5.0 + * + * @param int|WP_Term $term Term ID or term object. + * @return string The route path with a leading slash for the given term, or an empty string if there is not a route. + */ +function rest_get_route_for_term( $term ) { + $term = get_term( $term ); + + if ( ! $term instanceof WP_Term ) { + return ''; + } + + $taxonomy = get_taxonomy( $term->taxonomy ); + if ( ! $taxonomy ) { + return ''; + } + + $controller = $taxonomy->get_rest_controller(); + if ( ! $controller ) { + return ''; + } + + $route = ''; + + // The only controller that works is the Terms controller. + if ( 'WP_REST_Terms_Controller' === get_class( $controller ) ) { + $namespace = 'wp/v2'; + $rest_base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + $route = sprintf( '/%s/%s/%d', $namespace, $rest_base, $term->term_id ); + } + + /** + * Filters the REST API route for a term. + * + * @since 5.5.0 + * + * @param string $route The route path. + * @param WP_Term $term The term object. + */ + return apply_filters( 'rest_route_for_term', $route, $term ); +} + +/** + * Gets the REST route for the currently queried object. + * + * @since 5.5.0 + * + * @return string The REST route of the resource, or an empty string if no resource identified. + */ +function rest_get_queried_resource_route() { + if ( is_singular() ) { + $route = rest_get_route_for_post( get_queried_object() ); + } elseif ( is_category() || is_tag() || is_tax() ) { + $route = rest_get_route_for_term( get_queried_object() ); + } elseif ( is_author() ) { + $route = '/wp/v2/users/' . get_queried_object_id(); + } else { + $route = ''; + } + + /** + * Filters the REST route for the currently queried object. + * + * @since 5.5.0 + * + * @param string $link The route with a leading slash, or an empty string. + */ + return apply_filters( 'rest_queried_resource_route', $route ); +} diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index bdbce398db..275727a5a8 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -1349,4 +1349,112 @@ class Tests_REST_API extends WP_UnitTestCase { array( new WP_REST_Response( 'rest' ), 'rest' ), ); } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_post_non_post() { + $this->assertEquals( '', rest_get_route_for_post( 'garbage' ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_post_invalid_post_type() { + register_post_type( 'invalid' ); + $post = self::factory()->post->create_and_get( array( 'post_type' => 'invalid' ) ); + unregister_post_type( 'invalid' ); + + $this->assertEquals( '', rest_get_route_for_post( $post ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_post_non_rest() { + $post = self::factory()->post->create_and_get( array( 'post_type' => 'custom_css' ) ); + $this->assertEquals( '', rest_get_route_for_post( $post ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_post_custom_controller() { + $post = self::factory()->post->create_and_get( array( 'post_type' => 'wp_block' ) ); + $this->assertEquals( '', rest_get_route_for_post( $post ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_post() { + $post = self::factory()->post->create_and_get(); + $this->assertEquals( '/wp/v2/posts/' . $post->ID, rest_get_route_for_post( $post ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_media() { + $post = self::factory()->attachment->create_and_get(); + $this->assertEquals( '/wp/v2/media/' . $post->ID, rest_get_route_for_post( $post ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_post_id() { + $post = self::factory()->post->create_and_get(); + $this->assertEquals( '/wp/v2/posts/' . $post->ID, rest_get_route_for_post( $post->ID ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_term_non_term() { + $this->assertEquals( '', rest_get_route_for_term( 'garbage' ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_term_invalid_term_type() { + register_taxonomy( 'invalid', 'post' ); + $term = self::factory()->term->create_and_get( array( 'taxonomy' => 'invalid' ) ); + unregister_taxonomy( 'invalid' ); + + $this->assertEquals( '', rest_get_route_for_term( $term ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_term_non_rest() { + $term = self::factory()->term->create_and_get( array( 'taxonomy' => 'post_format' ) ); + $this->assertEquals( '', rest_get_route_for_term( $term ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_term() { + $term = self::factory()->term->create_and_get(); + $this->assertEquals( '/wp/v2/tags/' . $term->term_id, rest_get_route_for_term( $term ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_category() { + $term = self::factory()->category->create_and_get(); + $this->assertEquals( '/wp/v2/categories/' . $term->term_id, rest_get_route_for_term( $term ) ); + } + + /** + * @ticket 49116 + */ + public function test_rest_get_route_for_term_id() { + $term = self::factory()->term->create_and_get(); + $this->assertEquals( '/wp/v2/tags/' . $term->term_id, rest_get_route_for_term( $term->term_id ) ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-taxonomies-controller.php b/tests/phpunit/tests/rest-api/rest-taxonomies-controller.php index e75dcd66c9..009198ec13 100644 --- a/tests/phpunit/tests/rest-api/rest-taxonomies-controller.php +++ b/tests/phpunit/tests/rest-api/rest-taxonomies-controller.php @@ -291,4 +291,32 @@ class WP_Test_REST_Taxonomies_Controller extends WP_Test_REST_Controller_Testcas $this->assertEquals( count( $taxonomies ), count( $data ) ); } + /** + * @ticket 49116 + */ + public function test_get_for_taxonomy_reuses_same_instance() { + $this->assertSame( + get_taxonomy( 'category' )->get_rest_controller(), + get_taxonomy( 'category' )->get_rest_controller() + ); + } + + /** + * @ticket 49116 + */ + public function test_get_for_taxonomy_returns_terms_controller_if_custom_class_not_specified() { + register_taxonomy( + 'test', + 'post', + array( + 'show_in_rest' => true, + ) + ); + + $this->assertInstanceOf( + WP_REST_Terms_Controller::class, + get_taxonomy( 'test' )->get_rest_controller() + ); + } + }