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() + ); + } + }