diff --git a/backport-changelog/6.8/7773.md b/backport-changelog/6.8/7773.md new file mode 100644 index 00000000000000..73cb8288a5d29a --- /dev/null +++ b/backport-changelog/6.8/7773.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7773 + +* https://github.com/WordPress/gutenberg/pull/66294 diff --git a/lib/compat/wordpress-6.8/class-gutenberg-rest-post-counts-controller.php b/lib/compat/wordpress-6.8/class-gutenberg-rest-post-counts-controller.php new file mode 100644 index 00000000000000..2f735e58997c7b --- /dev/null +++ b/lib/compat/wordpress-6.8/class-gutenberg-rest-post-counts-controller.php @@ -0,0 +1,190 @@ +namespace = 'wp/v2'; + $this->rest_base = 'counts'; + } + + /** + * Registers the routes for post counts. + * + * @since 6.8.0 + * + * @see register_rest_route() + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\w-]+)', + array( + 'args' => array( + 'post_type' => array( + 'description' => __( 'An alphanumeric identifier for the post type.' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read post counts. + * + * @since 6.8.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $post_type = get_post_type_object( $request['post_type'] ); + + if ( empty( $post_type ) ) { + return new WP_Error( + 'rest_invalid_post_type', + __( 'Invalid post type.' ), + array( 'status' => 404 ) + ); + } + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read post counts for this post type.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves post counts for a specific post type. + * + * @since 6.8.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 ) { + $post_type = $request['post_type']; + $counts = wp_count_posts( $post_type ); + $data = $this->prepare_item_for_response( $counts, $request ); + + return rest_ensure_response( $data ); + } + + /** + * Prepares post counts for response. + * + * @since 6.8.0 + * + * @param object $item Post counts data. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $data = array(); + + if ( ! empty( $item ) ) { + /* + * The fields comprise all non-internal post statuses, + * including any custom statuses that may be registered. + * 'trash' is an exception, so if it exists, it is added separately. + */ + $post_stati = get_post_stati( array( 'internal' => false ) ); + + if ( get_post_status_object( 'trash' ) ) { + $post_stati[] = 'trash'; + } + // Include all public statuses in the response if there is a count. + foreach ( $post_stati as $status ) { + if ( isset( $item->$status ) ) { + $data[ $status ] = (int) $item->$status; + } + } + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the post counts schema, conforming to JSON Schema. + * + * @since 6.8.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + /* + * The fields comprise all non-internal post stati, + * including any custom statuses that may be registered. + * 'trash' is an exception, so if it exists, it is added separately. + * The caveat is that all custom post statuses + * must be registered at the highest priority, otherwise + * the endpoint will not return them. + */ + $post_statuses = get_post_stati( array( 'internal' => false ) ); + + if ( get_post_status_object( 'trash' ) ) { + $post_statuses[] = 'trash'; + } + $schema_properties = array(); + foreach ( $post_statuses as $post_status ) { + $schema_properties[ $post_status ] = array( + // translators: %s: Post status. + 'description' => sprintf( __( 'The number of posts with the status %s.' ), $post_status ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'post-counts', + 'type' => 'object', + 'properties' => $schema_properties, + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/lib/compat/wordpress-6.8/rest-api.php b/lib/compat/wordpress-6.8/rest-api.php index b94e42d5f2ccd0..6277b5b7e1ef19 100644 --- a/lib/compat/wordpress-6.8/rest-api.php +++ b/lib/compat/wordpress-6.8/rest-api.php @@ -87,3 +87,13 @@ function gutenberg_add_default_template_types_to_index( WP_REST_Response $respon } add_filter( 'rest_index', 'gutenberg_add_default_template_types_to_index' ); + +/** + * Registers the Post Counts REST API routes. + */ +function gutenberg_register_post_counts_routes() { + $post_counts_controller = new Gutenberg_REST_Post_Counts_Controller(); + $post_counts_controller->register_routes(); +} + +add_action( 'rest_api_init', 'gutenberg_register_post_counts_routes' ); diff --git a/lib/load.php b/lib/load.php index 371f9c54e5fc4a..ddf4f4464f94a0 100644 --- a/lib/load.php +++ b/lib/load.php @@ -46,6 +46,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-comment-controller-6-8.php'; require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-post-types-controller-6-8.php'; require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php'; + require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-post-counts-controller.php'; require __DIR__ . '/compat/wordpress-6.8/rest-api.php'; // Plugin specific code. diff --git a/phpunit/class-gutenberg-rest-post-counts-controller-test.php b/phpunit/class-gutenberg-rest-post-counts-controller-test.php new file mode 100644 index 00000000000000..6f21663d8df392 --- /dev/null +++ b/phpunit/class-gutenberg-rest-post-counts-controller-test.php @@ -0,0 +1,234 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + public function set_up() { + parent::set_up(); + + register_post_type( + 'private-cpt', + array( + 'public' => false, + 'publicly_queryable' => false, + 'show_ui' => true, + 'show_in_menu' => true, + 'show_in_rest' => true, + 'rest_base' => 'private-cpts', + 'capability_type' => 'post', + ) + ); + } + + public function tear_down() { + unregister_post_type( 'private-cpt' ); + parent::tear_down(); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/counts/(?P[\w-]+)', $routes ); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/counts/post' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertCount( 6, $properties ); + $this->assertArrayHasKey( 'publish', $properties ); + $this->assertArrayHasKey( 'future', $properties ); + $this->assertArrayHasKey( 'draft', $properties ); + $this->assertArrayHasKey( 'trash', $properties ); + $this->assertArrayHasKey( 'private', $properties ); + $this->assertArrayHasKey( 'pending', $properties ); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::get_item + */ + public function test_get_item_response() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/counts/post' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'publish', $data ); + $this->assertArrayHasKey( 'future', $data ); + $this->assertArrayHasKey( 'draft', $data ); + $this->assertArrayHasKey( 'pending', $data ); + $this->assertArrayHasKey( 'private', $data ); + $this->assertArrayHasKey( 'trash', $data ); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + register_post_status( 'post_counts_status', array( 'public' => true ) ); + + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $future = self::factory()->post->create( + array( + 'post_status' => 'future', + 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '+1 day' ) ), + ) + ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + $pending = self::factory()->post->create( array( 'post_status' => 'pending' ) ); + $private = self::factory()->post->create( array( 'post_status' => 'private' ) ); + $trashed = self::factory()->post->create( array( 'post_status' => 'trash' ) ); + $custom = self::factory()->post->create( array( 'post_status' => 'post_counts_status' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/counts/post' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 1, $data['publish'], 'Published post count mismatch.' ); + $this->assertSame( 1, $data['future'], 'Future post count mismatch.' ); + $this->assertSame( 1, $data['draft'], 'Draft post count mismatch.' ); + $this->assertSame( 1, $data['pending'], 'Pending post count mismatch.' ); + $this->assertSame( 1, $data['private'], 'Private post count mismatch.' ); + $this->assertSame( 1, $data['trash'], 'Trashed post count mismatch.' ); + $this->assertSame( 1, $data['post_counts_status'], 'Custom post count mismatch.' ); + + wp_delete_post( $published, true ); + wp_delete_post( $future, true ); + wp_delete_post( $draft, true ); + wp_delete_post( $pending, true ); + wp_delete_post( $private, true ); + wp_delete_post( $trashed, true ); + wp_delete_post( $custom, true ); + unset( $GLOBALS['wp_post_statuses']['post_counts_status'] ); + } + + /** + * @covers WP_REST_Post_Counts_Controller::get_item + */ + public function test_get_item_with_sanitized_custom_post_status() { + wp_set_current_user( self::$admin_id ); + register_post_status( '#<>post-me_AND9!', array( 'public' => true ) ); + + $custom = self::factory()->post->create( array( 'post_status' => 'post-me_and9' ) ); + $request = new WP_REST_Request( 'GET', '/wp/v2/counts/post' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 1, $data['post-me_and9'], 'Custom post count mismatch.' ); + + wp_delete_post( $custom, true ); + unset( $GLOBALS['wp_post_statuses']['post-me_and9'] ); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::get_item_permissions_check + */ + public function test_get_item_private_post_type() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/counts/private-cpt' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::get_item_permissions_check + */ + public function test_get_item_invalid_post_type() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/counts/invalid-type' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_post_type', $response, 404 ); + } + + /** + * @covers Gutenberg_REST_Post_Counts_Controller::get_item_permissions_check + */ + public function test_get_item_invalid_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/counts/post' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Controller does not implement delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not implement delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not implement test_create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not implement test_update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Controller does not implement test_prepare_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Controller does not implement context_param(). + } +}