REST API: Expose all themes in the themes controller.

Previously, only the active theme was made available. This commit allows for all themes to be queried if the user has the `switch_themes` or `manage_network_themes` capabilities.

This commit also no longer exposes the `page`, `per_page`, `search` and `context` query parameters since they are not supported by this controller.

Props spacedmonkey, lpawlik, TimothyBlynJacobs.
Fixes #50152.


git-svn-id: https://develop.svn.wordpress.org/trunk@49925 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Timothy Jacobs 2021-01-03 21:45:42 +00:00
parent 087ff2f8ef
commit 69cc6e61d0
6 changed files with 436 additions and 59 deletions

View File

@ -1244,8 +1244,8 @@ class WP_REST_Server {
);
$response = new WP_REST_Response( $available );
$response->add_link( 'help', 'http://v2.wp-api.org/' );
$this->add_active_theme_link_to_index( $response );
/**
* Filters the REST API root index data.
@ -1261,6 +1261,35 @@ class WP_REST_Server {
return apply_filters( 'rest_index', $response );
}
/**
* Adds a link to the active theme for users who have proper permissions.
*
* @since 5.7.0
*
* @param WP_REST_Response $response REST API response.
*/
protected function add_active_theme_link_to_index( WP_REST_Response $response ) {
$should_add = current_user_can( 'switch_themes' ) || current_user_can( 'manage_network_themes' );
if ( ! $should_add && current_user_can( 'edit_posts' ) ) {
$should_add = true;
}
if ( ! $should_add ) {
foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
if ( current_user_can( $post_type->cap->edit_posts ) ) {
$should_add = true;
break;
}
}
}
if ( $should_add ) {
$theme = wp_get_theme();
$response->add_link( 'https://api.w.org/active-theme', rest_url( 'wp/v2/themes/' . $theme->get_stylesheet() ) );
}
}
/**
* Retrieves the index for a namespace.
*

View File

@ -47,6 +47,25 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<stylesheet>[\w-]+)',
array(
'args' => array(
'stylesheet' => array(
'description' => __( "The theme's stylesheet. This uniquely identifies the theme." ),
'type' => 'string',
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
@ -58,6 +77,57 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
* @return true|WP_Error True if the request has read access for the item, otherwise WP_Error object.
*/
public function get_items_permissions_check( $request ) {
if ( current_user_can( 'switch_themes' ) || current_user_can( 'manage_network_themes' ) ) {
return true;
}
$registered = $this->get_collection_params();
if ( isset( $registered['status'], $request['status'] ) && is_array( $request['status'] ) && array( 'active' ) === $request['status'] ) {
return $this->check_read_active_theme_permission();
}
return new WP_Error(
'rest_cannot_view_themes',
__( 'Sorry, you are not allowed to view themes.' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Checks if a given request has access to read the theme.
*
* @since 5.7.0
*
* @param WP_REST_Request $request Full details about the request.
* @return bool|WP_Error True if the request has read access for the item, otherwise WP_Error object.
*/
public function get_item_permissions_check( $request ) {
if ( current_user_can( 'switch_themes' ) || current_user_can( 'manage_network_themes' ) ) {
return true;
}
$wp_theme = wp_get_theme( $request['stylesheet'] );
$current_theme = wp_get_theme();
if ( $this->is_same_theme( $wp_theme, $current_theme ) ) {
return $this->check_read_active_theme_permission();
}
return new WP_Error(
'rest_cannot_view_themes',
__( 'Sorry, you are not allowed to view themes.' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Checks if a theme can be read.
*
* @since 5.7.0
*
* @return bool|WP_Error Whether the theme can be read.
*/
protected function check_read_active_theme_permission() {
if ( current_user_can( 'edit_posts' ) ) {
return true;
}
@ -69,12 +139,34 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
}
return new WP_Error(
'rest_user_cannot_view',
__( 'Sorry, you are not allowed to view themes.' ),
'rest_cannot_view_active_theme',
__( 'Sorry, you are not allowed to view the active theme.' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Retrieves a single theme.
*
* @since 5.7.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_item( $request ) {
$wp_theme = wp_get_theme( $request['stylesheet'] );
if ( ! $wp_theme->exists() ) {
return new WP_Error(
'rest_theme_not_found',
__( 'Theme not found.' ),
array( 'status' => 404 )
);
}
$data = $this->prepare_item_for_response( $wp_theme, $request );
return rest_ensure_response( $data );
}
/**
* Retrieves a collection of themes.
*
@ -84,20 +176,26 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
* @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();
$themes = array();
$themes = array();
if ( isset( $registered['status'], $request['status'] ) && in_array( 'active', $request['status'], true ) ) {
$active_theme = wp_get_theme();
$active_theme = $this->prepare_item_for_response( $active_theme, $request );
$themes[] = $this->prepare_response_for_collection( $active_theme );
$active_themes = wp_get_themes();
$current_theme = wp_get_theme();
$status = $request['status'];
foreach ( $active_themes as $theme_name => $theme ) {
$theme_status = ( $this->is_same_theme( $theme, $current_theme ) ) ? 'active' : 'inactive';
if ( is_array( $status ) && ! in_array( $theme_status, $status, true ) ) {
continue;
}
$prepared = $this->prepare_item_for_response( $theme, $request );
$themes[] = $this->prepare_response_for_collection( $prepared );
}
$response = rest_ensure_response( $themes );
$response->header( 'X-WP-Total', count( $themes ) );
$response->header( 'X-WP-TotalPages', count( $themes ) );
$response->header( 'X-WP-TotalPages', 1 );
return $response;
}
@ -166,7 +264,12 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
}
}
if ( rest_is_field_included( 'theme_supports', $fields ) ) {
$current_theme = wp_get_theme();
if ( rest_is_field_included( 'status', $fields ) ) {
$data['status'] = ( $this->is_same_theme( $theme, $current_theme ) ) ? 'active' : 'inactive';
}
if ( rest_is_field_included( 'theme_supports', $fields ) && $this->is_same_theme( $theme, $current_theme ) ) {
foreach ( get_registered_theme_features() as $feature => $config ) {
if ( ! is_array( $config['show_in_rest'] ) ) {
continue;
@ -206,6 +309,8 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $theme ) );
/**
* Filters theme data returned from the REST API.
*
@ -218,6 +323,39 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
return apply_filters( 'rest_prepare_theme', $response, $theme, $request );
}
/**
* Prepares links for the request.
*
* @since 5.7.0
*
* @param WP_Theme $theme Theme data.
* @return array Links for the given block type.
*/
protected function prepare_links( $theme ) {
return array(
'self' => array(
'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $theme->get_stylesheet() ) ),
),
'collection' => array(
'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
),
);
}
/**
* Helper function to compare two themes.
*
* @since 5.7.0
*
* @param WP_Theme $theme_a First theme to compare.
* @param WP_Theme $theme_b Second theme to compare.
*
* @return bool
*/
protected function is_same_theme( $theme_a, $theme_b ) {
return $theme_a->get_stylesheet() === $theme_b->get_stylesheet();
}
/**
* Prepares the theme support value for inclusion in the REST API response.
*
@ -399,6 +537,11 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
'type' => 'string',
'readonly' => true,
),
'status' => array(
'description' => __( 'A named status for the theme.' ),
'type' => 'string',
'enum' => array( 'inactive', 'active' ),
),
),
);
@ -425,17 +568,15 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
* @return array Collection parameters.
*/
public function get_collection_params() {
$query_params = parent::get_collection_params();
$query_params['status'] = array(
'description' => __( 'Limit result set to themes assigned one or more statuses.' ),
'type' => 'array',
'items' => array(
'enum' => array( 'active' ),
'type' => 'string',
$query_params = array(
'status' => array(
'description' => __( 'Limit result set to themes assigned one or more statuses.' ),
'type' => 'array',
'items' => array(
'enum' => array( 'active', 'inactive' ),
'type' => 'string',
),
),
'required' => true,
'sanitize_callback' => array( $this, 'sanitize_theme_status' ),
);
/**
@ -452,6 +593,7 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
* Sanitizes and validates the list of theme status.
*
* @since 5.0.0
* @deprecated 5.7.0
*
* @param string|array $statuses One or more theme statuses.
* @param WP_REST_Request $request Full details about the request.
@ -459,6 +601,8 @@ class WP_REST_Themes_Controller extends WP_REST_Controller {
* @return array|WP_Error A list of valid statuses, otherwise WP_Error object.
*/
public function sanitize_theme_status( $statuses, $request, $parameter ) {
_deprecated_function( __METHOD__, '5.7.0' );
$statuses = wp_parse_slug_list( $statuses );
foreach ( $statuses as $status ) {

View File

@ -129,6 +129,7 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase {
'/wp/v2/block-types/(?P<namespace>[a-zA-Z0-9_-]+)/(?P<name>[a-zA-Z0-9_-]+)',
'/wp/v2/settings',
'/wp/v2/themes',
'/wp/v2/themes/(?P<stylesheet>[\w-]+)',
'/wp/v2/plugins',
'/wp/v2/plugins/(?P<plugin>[^.\/]+(?:\/[^.\/]+)?)',
'/wp/v2/block-directory/search',

View File

@ -959,6 +959,9 @@ class Tests_REST_Server extends WP_Test_REST_TestCase {
$this->assertContains( 'GET', $route['methods'] );
$this->assertContains( 'DELETE', $route['methods'] );
$this->assertArrayHasKey( '_links', $route );
$this->assertArrayHasKey( 'help', $index->get_links() );
$this->assertArrayNotHasKey( 'wp:active-theme', $index->get_links() );
}
public function test_get_namespace_index() {
@ -1996,6 +1999,16 @@ class Tests_REST_Server extends WP_Test_REST_TestCase {
$this->assertSameSetsWithIndex( $expected, $args['param'] );
}
/**
* @ticket 50152
*/
public function test_index_includes_link_to_active_theme_if_authenticated() {
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
$index = rest_do_request( '/' );
$this->assertArrayHasKey( 'https://api.w.org/active-theme', $index->get_links() );
}
public function _validate_as_integer_123( $value, $request, $key ) {
if ( ! is_int( $value ) ) {
return new WP_Error( 'some-error', 'This is not valid!' );

View File

@ -29,6 +29,15 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
*/
protected static $contributor_id;
/**
* Admin user ID.
*
* @since 5.7.0
*
* @var int $admin_id
*/
protected static $admin_id;
/**
* The current theme object.
*
@ -91,6 +100,11 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
* @param WP_UnitTest_Factory $factory WordPress unit test factory.
*/
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$admin_id = $factory->user->create(
array(
'role' => 'administrator',
)
);
self::$subscriber_id = $factory->user->create(
array(
'role' => 'subscriber',
@ -114,6 +128,7 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
public static function wpTearDownAfterClass() {
self::delete_user( self::$subscriber_id );
self::delete_user( self::$contributor_id );
self::delete_user( self::$admin_id );
}
/**
@ -136,6 +151,7 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
public function test_register_routes() {
$routes = rest_get_server()->get_routes();
$this->assertArrayHasKey( self::$themes_route, $routes );
$this->assertArrayHasKey( self::$themes_route . '/(?P<stylesheet>[\\w-]+)', $routes );
}
/**
@ -151,6 +167,7 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
$this->check_get_theme_response( $response );
$fields = array(
'_links',
'author',
'author_uri',
'description',
@ -158,6 +175,7 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
'requires_php',
'requires_wp',
'screenshot',
'status',
'stylesheet',
'tags',
'template',
@ -170,23 +188,126 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
}
/**
* @ticket 46723
* Test retrieving a collection of inactive themes.
*
* @ticket 50152
*/
public function test_get_items_logged_out() {
public function test_get_items_inactive() {
wp_set_current_user( self::$admin_id );
$request = new WP_REST_Request( 'GET', self::$themes_route );
$request->set_param( 'status', 'inactive' );
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$data = $response->get_data();
$fields = array(
'_links',
'author',
'author_uri',
'description',
'name',
'requires_php',
'requires_wp',
'screenshot',
'status',
'stylesheet',
'tags',
'template',
'textdomain',
'theme_uri',
'version',
);
$this->assertEqualSets( $fields, array_keys( $data[0] ) );
$this->assertContains( 'twentytwenty', wp_list_pluck( $data, 'stylesheet' ) );
$this->assertNotContains( get_stylesheet(), wp_list_pluck( $data, 'stylesheet' ) );
}
/**
* Test retrieving a collection of inactive themes.
*
* @ticket 50152
*/
public function test_get_items_active_and_inactive() {
wp_set_current_user( self::$admin_id );
$request = new WP_REST_Request( 'GET', self::$themes_route );
$request->set_param( 'status', array( 'active', 'inactive' ) );
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$data = $response->get_data();
$this->assertContains( 'twentytwenty', wp_list_pluck( $data, 'stylesheet' ) );
$this->assertContains( get_stylesheet(), wp_list_pluck( $data, 'stylesheet' ) );
}
/**
* @ticket 46723
* @ticket 50152
* @dataProvider data_get_items_by_status
*/
public function test_get_items_logged_out( $status, $error_code ) {
wp_set_current_user( 0 );
$response = self::perform_active_theme_request();
$this->assertErrorResponse( 'rest_user_cannot_view', $response, 401 );
$request = new WP_REST_Request( 'GET', self::$themes_route );
$request->set_param( 'status', $status );
$response = rest_get_server()->dispatch( $request );
$this->assertErrorResponse( $error_code, $response, 401 );
}
/**
* An error should be returned when the user does not have the edit_posts capability.
*
* @ticket 45016
* @ticket 50152
* @dataProvider data_get_items_by_status
*/
public function test_get_items_no_permission() {
public function test_get_items_no_permission( $status, $error_code ) {
wp_set_current_user( self::$subscriber_id );
$response = self::perform_active_theme_request();
$this->assertErrorResponse( 'rest_user_cannot_view', $response, 403 );
$request = new WP_REST_Request( 'GET', self::$themes_route );
$request->set_param( 'status', $status );
$response = rest_get_server()->dispatch( $request );
$this->assertErrorResponse( $error_code, $response, 403 );
}
public function data_get_items_by_status() {
return array(
array( 'active', 'rest_cannot_view_active_theme' ),
array( 'active, inactive', 'rest_cannot_view_themes' ),
array( 'inactive', 'rest_cannot_view_themes' ),
array( '', 'rest_cannot_view_themes' ),
);
}
/**
* @ticket 50152
* @dataProvider data_get_items_by_status_for_contributor
*/
public function test_get_items_contributor( $status, $error_code ) {
wp_set_current_user( self::$contributor_id );
$request = new WP_REST_Request( 'GET', self::$themes_route );
$request->set_param( 'status', $status );
$response = rest_get_server()->dispatch( $request );
if ( $error_code ) {
$this->assertErrorResponse( $error_code, $response, 403 );
} else {
$this->assertEquals( 200, $response->get_status() );
}
}
public function data_get_items_by_status_for_contributor() {
return array(
array( 'active', '' ),
array( 'active, inactive', 'rest_cannot_view_themes' ),
array( 'inactive', 'rest_cannot_view_themes' ),
array( '', 'rest_cannot_view_themes' ),
);
}
/**
@ -221,7 +342,7 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
$response = self::perform_active_theme_request( 'OPTIONS' );
$data = $response->get_data();
$properties = $data['schema']['properties'];
$this->assertSame( 14, count( $properties ) );
$this->assertSame( 15, count( $properties ) );
$this->assertArrayHasKey( 'author', $properties );
$this->assertArrayHasKey( 'raw', $properties['author']['properties'] );
@ -242,6 +363,7 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
$this->assertArrayHasKey( 'requires_php', $properties );
$this->assertArrayHasKey( 'requires_wp', $properties );
$this->assertArrayHasKey( 'screenshot', $properties );
$this->assertArrayHasKey( 'status', $properties );
$this->assertArrayHasKey( 'stylesheet', $properties );
$this->assertArrayHasKey( 'tags', $properties );
@ -1083,9 +1205,81 @@ class WP_Test_REST_Themes_Controller extends WP_Test_REST_Controller_Testcase {
public function test_update_item() {}
/**
* The get_item() method does not exist for themes.
* Test single theme
*
* @ticket 50152
*/
public function test_get_item() {}
public function test_get_item() {
wp_set_current_user( self::$admin_id );
$route = sprintf( '%s/%s', self::$themes_route, WP_DEFAULT_THEME );
$request = new WP_REST_Request( 'GET', $route );
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$data = $response->get_data();
$links = $response->get_links();
$fields = array(
'author',
'author_uri',
'description',
'name',
'requires_php',
'requires_wp',
'screenshot',
'status',
'stylesheet',
'tags',
'template',
'textdomain',
'theme_uri',
'version',
);
$fields_links = array( 'collection', 'self' );
$this->assertEqualSets( $fields, array_keys( $data ) );
$this->assertEqualSets( $fields_links, array_keys( $links ) );
}
/**
* @ticket 50152
*/
public function test_get_item_no_permission() {
wp_set_current_user( self::$subscriber_id );
$request = new WP_REST_Request( 'GET', self::$themes_route . '/' . WP_DEFAULT_THEME );
$response = rest_get_server()->dispatch( $request );
$this->assertErrorResponse( 'rest_cannot_view_themes', $response, 403 );
}
/**
* @ticket 50152
*/
public function test_get_active_item_no_permission() {
wp_set_current_user( self::$subscriber_id );
$request = new WP_REST_Request( 'GET', self::$themes_route . '/' . get_stylesheet() );
$response = rest_get_server()->dispatch( $request );
$this->assertErrorResponse( 'rest_cannot_view_active_theme', $response, 403 );
}
/**
* @ticket 50152
*/
public function test_get_item_invalid() {
wp_set_current_user( self::$admin_id );
$request = new WP_REST_Request( 'GET', self::$themes_route . '/invalid' );
$response = rest_get_server()->dispatch( $request );
$this->assertErrorResponse( 'rest_theme_not_found', $response, 404 );
}
/**
* @ticket 50152
*/
public function test_get_active_item_as_contributor() {
$route = sprintf( '%s/%s', self::$themes_route, get_stylesheet() );
$request = new WP_REST_Request( 'GET', $route );
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
}
/**
* The delete_item() method does not exist for themes.

View File

@ -5784,41 +5784,17 @@ mockedApiResponse.Schema = {
"GET"
],
"args": {
"context": {
"description": "Scope under which the request is made; determines fields present in response.",
"type": "string",
"required": false
},
"page": {
"description": "Current page of the collection.",
"type": "integer",
"default": 1,
"minimum": 1,
"required": false
},
"per_page": {
"description": "Maximum number of items to be returned in result set.",
"type": "integer",
"default": 10,
"minimum": 1,
"maximum": 100,
"required": false
},
"search": {
"description": "Limit results to those matching a string.",
"type": "string",
"required": false
},
"status": {
"description": "Limit result set to themes assigned one or more statuses.",
"type": "array",
"items": {
"enum": [
"active"
"active",
"inactive"
],
"type": "string"
},
"required": true
"required": false
}
}
}
@ -5827,6 +5803,26 @@ mockedApiResponse.Schema = {
"self": "http://example.org/index.php?rest_route=/wp/v2/themes"
}
},
"/wp/v2/themes/(?P<stylesheet>[\\w-]+)": {
"namespace": "wp/v2",
"methods": [
"GET"
],
"endpoints": [
{
"methods": [
"GET"
],
"args": {
"stylesheet": {
"description": "The theme's stylesheet. This uniquely identifies the theme.",
"type": "string",
"required": false
}
}
}
]
},
"/wp/v2/plugins": {
"namespace": "wp/v2",
"methods": [