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