From a3c2f01fead9db74093f53f36bccefb21b852af5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:19:56 -0700 Subject: [PATCH 1/6] Comments: Add a REST API endpoint for comment types. Expose registered comment types through a read-only `/wp/v2/comment-types` controller, mirroring the post types controller (`/wp/v2/types`). This lets REST clients discover the registered types and their labels, which the block editor's inline-commenting work needs in order to add and query comments by type. Add a `show_in_rest` argument to `WP_Comment_Type` (defaulting to the value of `public`, as `show_ui` does) to gate which types the endpoint exposes. The built-in `comment`, `pingback`, and `trackback` types are public and therefore visible; the internal `note` type is not. Builds on the registration API in #12311. See #35214. --- src/wp-includes/class-wp-comment-type.php | 29 +- src/wp-includes/rest-api.php | 4 + ...class-wp-rest-comment-types-controller.php | 315 ++++++++++++++++++ src/wp-settings.php | 1 + 4 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 6f88c5e7b499d..5e8aa923f6012 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -94,6 +94,17 @@ final class WP_Comment_Type { */ public $show_ui; + /** + * Whether to include this comment type in the REST API. + * + * Comment types with this enabled are exposed by the `/wp/v2/comment-types` + * endpoint. Default is the value of $public. + * + * @since 7.1.0 + * @var bool + */ + public $show_in_rest; + /** * Whether this comment type is a native or "built-in" comment type. * @@ -187,12 +198,13 @@ public function set_props( $args ) { * treated as a provided value and overwrite the default name with false. */ $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => true, - 'internal' => false, - 'show_ui' => null, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'show_ui' => null, + 'show_in_rest' => null, + '_builtin' => false, ); $args = array_merge( $defaults, $args ); @@ -202,6 +214,11 @@ public function set_props( $args ) { $args['show_ui'] = $args['public']; } + // If not set, default to the setting for 'public'. + if ( null === $args['show_in_rest'] ) { + $args['show_in_rest'] = $args['public']; + } + $args['name'] = $this->name; foreach ( $args as $property_name => $property_value ) { diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a4c22e8f1cca1..c49202811a8f1 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -324,6 +324,10 @@ function create_initial_rest_routes() { $controller = new WP_REST_Comments_Controller(); $controller->register_routes(); + // Comment types. + $controller = new WP_REST_Comment_Types_Controller(); + $controller->register_routes(); + $search_handlers = array( new WP_REST_Post_Search_Handler(), new WP_REST_Term_Search_Handler(), diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php new file mode 100644 index 0000000000000..f5913d1d9242a --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php @@ -0,0 +1,315 @@ +namespace = 'wp/v2'; + $this->rest_base = 'comment-types'; + } + + /** + * Registers the routes for comment types. + * + * @since 7.1.0 + * + * @see register_rest_route() + */ + public function register_routes() { + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\w-]+)', + array( + 'args' => array( + 'type' => array( + 'description' => __( 'An alphanumeric identifier for the comment 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 whether a given request has permission to read comment types. + * + * @since 7.1.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_items_permissions_check( $request ) { + if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_cannot_view', + __( 'Sorry, you are not allowed to manage comments.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves all public comment types. + * + * @since 7.1.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_items( $request ) { + if ( $request->is_method( 'HEAD' ) ) { + // Return early as this handler doesn't add any response headers. + return new WP_REST_Response( array() ); + } + + $data = array(); + $types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); + + foreach ( $types as $type ) { + $comment_type = $this->prepare_item_for_response( $type, $request ); + $data[ $type->name ] = $this->prepare_response_for_collection( $comment_type ); + } + + return rest_ensure_response( $data ); + } + + /** + * Checks if a given request has access to read a comment type. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to manage comments.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves a specific comment type. + * + * @since 7.1.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 ) { + $obj = get_comment_type_object( $request['type'] ); + + if ( empty( $obj ) ) { + return new WP_Error( + 'rest_type_invalid', + __( 'Invalid comment type.' ), + array( 'status' => 404 ) + ); + } + + if ( empty( $obj->show_in_rest ) ) { + return new WP_Error( + 'rest_cannot_read_type', + __( 'Cannot view comment type.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $data = $this->prepare_item_for_response( $obj, $request ); + + return rest_ensure_response( $data ); + } + + /** + * Prepares a comment type object for serialization. + * + * @since 7.1.0 + * + * @param WP_Comment_Type $item Comment type object. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + // Restores the more descriptive, specific name for use within this method. + $comment_type = $item; + + // Don't prepare the response body for HEAD requests. + if ( $request->is_method( 'HEAD' ) ) { + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php */ + return apply_filters( 'rest_prepare_comment_type', new WP_REST_Response( array() ), $comment_type, $request ); + } + + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'description', $fields ) ) { + $data['description'] = $comment_type->description; + } + + if ( rest_is_field_included( 'labels', $fields ) ) { + $data['labels'] = $comment_type->labels; + } + + if ( rest_is_field_included( 'name', $fields ) ) { + $data['name'] = $comment_type->label; + } + + if ( rest_is_field_included( 'slug', $fields ) ) { + $data['slug'] = $comment_type->name; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $response->add_links( $this->prepare_links( $comment_type ) ); + } + + /** + * Filters a comment type returned from the REST API. + * + * Allows modification of the comment type data right before it is returned. + * + * @since 7.1.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Comment_Type $comment_type The original comment type object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_comment_type', $response, $comment_type, $request ); + } + + /** + * Prepares links for the request. + * + * @since 7.1.0 + * + * @param WP_Comment_Type $comment_type The comment type. + * @return array Links for the given comment type. + */ + protected function prepare_links( $comment_type ) { + return array( + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + 'https://api.w.org/items' => array( + 'href' => add_query_arg( 'type', $comment_type->name, rest_url( 'wp/v2/comments' ) ), + ), + ); + } + + /** + * Retrieves the comment type's schema, conforming to JSON Schema. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'comment-type', + 'type' => 'object', + 'properties' => array( + 'description' => array( + 'description' => __( 'A human-readable description of the comment type.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'labels' => array( + 'description' => __( 'Human-readable labels for the comment type for various contexts.' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'The title for the comment type.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'slug' => array( + 'description' => __( 'An alphanumeric identifier for the comment type.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 7.1.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 9ec2c3607271d..56869e68e6c40 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -334,6 +334,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-menu-locations-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-users-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-comments-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-comment-types-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-search-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-blocks-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-types-controller.php'; From c0d63b6611d3d4e2658c7a3ed50a03595f9ca494 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:20:01 -0700 Subject: [PATCH 2/6] Comments: Add tests for the comment types REST API endpoint. Cover the `/wp/v2/comment-types` controller: route registration, the context param, listing public types while excluding non-REST types (`note` and types that opt out via `show_in_rest`), single-type reads, permission checks for the `edit` context, and the item schema. Also assert the `show_in_rest` cascade on `WP_Comment_Type` and add the two new routes to the REST schema route list. See #35214. --- tests/phpunit/tests/comment/types.php | 26 ++ .../rest-comment-types-controller.php | 248 ++++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 2 + 3 files changed, 276 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/rest-comment-types-controller.php diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php index 54f8e2aa9ad33..bd5d845e445db 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -98,6 +98,32 @@ public function test_register_comment_type_show_ui_should_default_to_value_of_pu $this->assertFalse( get_comment_type_object( 'private_type' )->show_ui ); } + /** + * @ticket 35214 + */ + public function test_register_comment_type_show_in_rest_should_default_to_value_of_public() { + register_comment_type( 'public_type', array( 'public' => true ) ); + $this->assertTrue( get_comment_type_object( 'public_type' )->show_in_rest ); + + register_comment_type( 'private_type', array( 'public' => false ) ); + $this->assertFalse( get_comment_type_object( 'private_type' )->show_in_rest ); + } + + /** + * @ticket 35214 + */ + public function test_register_comment_type_show_in_rest_can_be_overridden() { + register_comment_type( + 'private_but_in_rest', + array( + 'public' => false, + 'show_in_rest' => true, + ) + ); + + $this->assertTrue( get_comment_type_object( 'private_but_in_rest' )->show_in_rest ); + } + /** * @ticket 35214 */ diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php new file mode 100644 index 0000000000000..db413f4dbdb11 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -0,0 +1,248 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * Ensures any comment type registered during a test is cleaned up. + */ + public function tear_down() { + global $wp_comment_types; + + foreach ( array_keys( $wp_comment_types ) as $comment_type ) { + if ( ! $wp_comment_types[ $comment_type ]->_builtin ) { + unset( $wp_comment_types[ $comment_type ] ); + } + } + + parent::tear_down(); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/comment-types', $routes ); + $this->assertArrayHasKey( '/wp/v2/comment-types/(?P[\w-]+)', $routes ); + } + + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSameSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSameSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + public function test_get_items() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $comment_types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); + $this->assertCount( count( $comment_types ), $data ); + $this->assertSame( $comment_types['comment']->name, $data['comment']['slug'] ); + $this->check_comment_type_obj( 'view', $comment_types['comment'], $data['comment'], $data['comment']['_links'] ); + $this->assertArrayHasKey( 'pingback', $data ); + $this->assertArrayHasKey( 'trackback', $data ); + } + + /** + * The internal `note` type is not exposed (show_in_rest defaults to public, which is false). + */ + public function test_get_items_excludes_non_rest_types() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'note', $data ); + } + + public function test_get_items_includes_registered_custom_type() { + register_comment_type( 'review', array( 'public' => true ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'review', $data ); + } + + public function test_get_items_excludes_custom_type_opted_out_of_rest() { + register_comment_type( + 'review', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'review', $data ); + } + + /** + * @dataProvider data_readable_http_methods + * + * @param string $method HTTP method to use. + */ + public function test_get_items_invalid_permission_for_context( $method ) { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( $method, '/wp/v2/comment-types' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); + } + + /** + * Data provider intended to provide HTTP method names for testing GET and HEAD requests. + * + * @return array + */ + public static function data_readable_http_methods() { + return array( + 'GET request' => array( 'GET' ), + 'HEAD request' => array( 'HEAD' ), + ); + } + + public function test_get_item() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $this->check_comment_type_object_response( 'view', $response ); + } + + public function test_get_item_invalid_type() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/invalid' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_type_invalid', $response, 404 ); + } + + public function test_get_item_non_rest_type_is_not_readable() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/note' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read_type', $response, 401 ); + } + + public function test_get_item_edit_context_requires_permission() { + wp_set_current_user( self::$subscriber_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + public function test_get_item_edit_context_returns_labels() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->check_comment_type_object_response( 'edit', $response ); + } + + public function test_create_item() { + /** Comment types can't be created */ + $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_update_item() { + /** Comment types can't be updated */ + $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_delete_item() { + /** Comment types can't be deleted */ + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_prepare_item() { + $obj = get_comment_type_object( 'comment' ); + $endpoint = new WP_REST_Comment_Types_Controller(); + $request = new WP_REST_Request(); + $request->set_param( 'context', 'view' ); + $response = $endpoint->prepare_item_for_response( $obj, $request ); + $this->check_comment_type_obj( 'view', $obj, $response->get_data(), $response->get_links() ); + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties ); + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'slug', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'labels', $properties ); + } + + protected function check_comment_type_obj( $context, $comment_type_obj, $data, $links ) { + $this->assertSame( $comment_type_obj->label, $data['name'] ); + $this->assertSame( $comment_type_obj->name, $data['slug'] ); + $this->assertSame( $comment_type_obj->description, $data['description'] ); + + $links = test_rest_expand_compact_links( $links ); + $this->assertSame( rest_url( 'wp/v2/comment-types' ), $links['collection'][0]['href'] ); + $this->assertArrayHasKey( 'https://api.w.org/items', $links ); + + if ( 'edit' === $context ) { + $this->assertSame( (array) $comment_type_obj->labels, (array) $data['labels'] ); + } else { + $this->assertArrayNotHasKey( 'labels', $data ); + } + } + + protected function check_comment_type_object_response( $context, $response, $comment_type = 'comment' ) { + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $obj = get_comment_type_object( $comment_type ); + $this->check_comment_type_obj( $context, $obj, $data, $response->get_links() ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..c1aa2977e6829 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -138,6 +138,8 @@ public function test_expected_routes_in_schema() { '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)', '/wp/v2/comments', '/wp/v2/comments/(?P[\\d]+)', + '/wp/v2/comment-types', + '/wp/v2/comment-types/(?P[\\w-]+)', '/wp/v2/global-styles/(?P[\/\d+]+)', '/wp/v2/global-styles/(?P[\d]+)/revisions', '/wp/v2/global-styles/(?P[\d]+)/revisions/(?P[\d]+)', From 7daed7e2c489a9ef868785313eda2fb560ee8868 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:25:51 -0700 Subject: [PATCH 3/6] Comments: Expand REST comment-types test coverage for HEAD requests. Add happy-path coverage for the controller's dedicated HEAD-request handling in get_items() and prepare_item_for_response(), mirroring the post-types controller (ticket 56481): a collection HEAD request returns 200 without preparing item data, single-item HEAD requests still run the rest_prepare_comment_type filter and allow header injection, and HEAD requests with _fields succeed. Also assert the api.w.org/items link points at the type-filtered comments collection. See #35214. --- .../rest-comment-types-controller.php | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php index db413f4dbdb11..2dfa184cbd342 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -223,6 +223,107 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'labels', $properties ); } + /** + * The `api.w.org/items` link should point at the type-filtered comments collection. + * + * @ticket 35214 + */ + public function test_get_item_links_to_filtered_comments_collection() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $links = test_rest_expand_compact_links( $response->get_links() ); + + $this->assertArrayHasKey( 'https://api.w.org/items', $links ); + $this->assertSame( + add_query_arg( 'type', 'comment', rest_url( 'wp/v2/comments' ) ), + $links['https://api.w.org/items'][0]['href'] + ); + } + + /** + * A HEAD request to the collection should succeed without preparing any item data. + * + * @ticket 56481 + */ + public function test_get_items_with_head_request_should_not_prepare_comment_types_data() { + $request = new WP_REST_Request( 'HEAD', '/wp/v2/comment-types' ); + $hook_name = 'rest_prepare_comment_type'; + $filter = new MockAction(); + $callback = array( $filter, 'filter' ); + add_filter( $hook_name, $callback ); + $response = rest_get_server()->dispatch( $request ); + remove_filter( $hook_name, $callback ); + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( 0, $filter->get_call_count(), 'The "' . $hook_name . '" filter was called when it should not be for HEAD requests.' ); + $this->assertSame( array(), $response->get_data(), 'The server should not generate a body in response to a HEAD request.' ); + } + + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method The HTTP method to use. + */ + public function test_get_item_should_allow_adding_headers_via_filter( $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/comment-types/comment' ); + + $hook_name = 'rest_prepare_comment_type'; + $filter = new MockAction(); + $callback = array( $filter, 'filter' ); + add_filter( $hook_name, $callback ); + $header_filter = new class() { + public static function add_custom_header( $response ) { + $response->header( 'X-Test-Header', 'Test' ); + + return $response; + } + }; + add_filter( $hook_name, array( $header_filter, 'add_custom_header' ) ); + $response = rest_get_server()->dispatch( $request ); + remove_filter( $hook_name, $callback ); + remove_filter( $hook_name, array( $header_filter, 'add_custom_header' ) ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( 1, $filter->get_call_count(), 'The "' . $hook_name . '" filter should be called once.' ); + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-Test-Header', $headers, 'The "X-Test-Header" header should be present in the response.' ); + $this->assertSame( 'Test', $headers['X-Test-Header'], 'The "X-Test-Header" header value should be equal to "Test".' ); + if ( 'HEAD' !== $method ) { + return null; + } + $this->assertSame( array(), $response->get_data(), 'The server should not generate a body in response to a HEAD request.' ); + } + + /** + * @dataProvider data_head_request_with_specified_fields_returns_success_response + * @ticket 56481 + * + * @param string $path The path to test. + */ + public function test_head_request_with_specified_fields_returns_success_response( $path ) { + $request = new WP_REST_Request( 'HEAD', $path ); + $request->set_param( '_fields', 'slug' ); + $server = rest_get_server(); + $response = $server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + } + + /** + * Data provider intended to provide paths for testing HEAD requests. + * + * @return array + */ + public static function data_head_request_with_specified_fields_returns_success_response() { + return array( + 'get_item request' => array( '/wp/v2/comment-types/comment' ), + 'get_items request' => array( '/wp/v2/comment-types' ), + ); + } + protected function check_comment_type_obj( $context, $comment_type_obj, $data, $links ) { $this->assertSame( $comment_type_obj->label, $data['name'] ); $this->assertSame( $comment_type_obj->name, $data['slug'] ); From 274dcb3772aacb5b1da73032aad6fb84a08c161a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:15:43 -0700 Subject: [PATCH 4/6] Comments: Clarify scope and tighten the comment types REST controller. - Document exactly what 'show_in_rest' gates (the read-only discovery endpoint) in the property docblock and the register_comment_type() args reference, including that it does not affect comment readability via /wp/v2/comments, and realign the args hash. - Document the deliberate divergence from WP_Post_Type in defaulting 'show_in_rest' from 'public': the false default there is pure back-compat for pre-REST post types, which a new registry lacks. - Reuse the existing 'not allowed to edit comments' string instead of introducing a new translator-facing string. - Correct the get_items() summary (it retrieves REST-exposed types, not public ones) and mark the seam where per-type capability checks land once comment type capabilities exist. - Tests: pin the anonymous 401-before-type-resolution ordering (a deliberate divergence from the post types controller's 404), cover HEAD for invalid types, _fields-limited responses, additional fields registered for 'comment-type', edit-context labels for all exposed types with 'note' still hidden from moderators, and add missing @ticket tags. Drop the hand-rolled tear_down() now handled by the test framework. --- src/wp-includes/comment.php | 31 ++-- ...class-wp-rest-comment-types-controller.php | 13 +- tests/phpunit/tests/comment/types.php | 6 +- .../rest-comment-types-controller.php | 163 ++++++++++++++++-- 4 files changed, 181 insertions(+), 32 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b01a5c3c4bd8e..85303be94bead 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -361,19 +361,24 @@ function create_initial_comment_types() { * @param array|string $args { * Optional. Array or string of arguments for registering a comment type. Default empty array. * - * @type string $label Name of the comment type. Usually plural. - * Default is the value of $labels['name']. - * @type string[] $labels An array of labels for this comment type. If not set, the - * default comment labels are used. See get_comment_type_labels() - * for a full list of supported labels. - * @type string $description A short descriptive summary of what the comment type is. - * Default empty. - * @type bool $public Whether the comment type is intended for use publicly either via - * the admin interface or by front-end users. Core does not - * currently act on this argument. Default true. - * @type bool $internal Whether the comment type is for internal use only. Core does not - * currently consult this flag; it is intended to drive default - * query exclusions in the future. Default false. + * @type string $label Name of the comment type. Usually plural. + * Default is the value of $labels['name']. + * @type string[] $labels An array of labels for this comment type. If not set, the + * default comment labels are used. See get_comment_type_labels() + * for a full list of supported labels. + * @type string $description A short descriptive summary of what the comment type is. + * Default empty. + * @type bool $public Whether the comment type is intended for use publicly either via + * the admin interface or by front-end users. Core does not + * currently act on this argument. Default true. + * @type bool $internal Whether the comment type is for internal use only. Core does not + * currently consult this flag; it is intended to drive default + * query exclusions in the future. Default false. + * @type bool $show_in_rest Whether to expose this comment type in the REST API comment types + * discovery endpoint (`/wp/v2/comment-types`). Exposing a type + * reveals its name, slug, description, and labels only; it does not + * make comments of this type readable or queryable via + * `/wp/v2/comments`. Default is the value of $public. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php index f5913d1d9242a..0ffee5f97a50c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php @@ -84,7 +84,7 @@ public function get_items_permissions_check( $request ) { if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_cannot_view', - __( 'Sorry, you are not allowed to manage comments.' ), + __( 'Sorry, you are not allowed to edit comments.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -93,7 +93,7 @@ public function get_items_permissions_check( $request ) { } /** - * Retrieves all public comment types. + * Retrieves comment types exposed in the REST API. * * @since 7.1.0 * @@ -109,6 +109,13 @@ public function get_items( $request ) { $data = array(); $types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); + /* + * Once per-comment-type capabilities exist, edit-context collection requests + * should skip types the current user cannot moderate here, rather than + * relying solely on the global 'moderate_comments' gate in + * get_items_permissions_check() (matching how the post types controller + * hides non-editable types from edit-context collections). + */ foreach ( $types as $type ) { $comment_type = $this->prepare_item_for_response( $type, $request ); $data[ $type->name ] = $this->prepare_response_for_collection( $comment_type ); @@ -129,7 +136,7 @@ public function get_item_permissions_check( $request ) { if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_forbidden_context', - __( 'Sorry, you are not allowed to manage comments.' ), + __( 'Sorry, you are not allowed to edit comments.' ), array( 'status' => rest_authorization_required_code() ) ); } diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php index 12345e010c96e..b07ff5cc5091c 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -78,7 +78,7 @@ public function test_register_comment_type_with_empty_name() { /** * @ticket 35214 * - * @covers ::create_initial_comment_types + * @covers ::register_comment_type */ public function test_register_comment_type_show_in_rest_should_default_to_value_of_public() { register_comment_type( 'public_type', array( 'public' => true ) ); @@ -90,6 +90,8 @@ public function test_register_comment_type_show_in_rest_should_default_to_value_ /** * @ticket 35214 + * + * @covers ::register_comment_type */ public function test_register_comment_type_show_in_rest_can_be_overridden() { register_comment_type( @@ -105,6 +107,8 @@ public function test_register_comment_type_show_in_rest_can_be_overridden() { /** * @ticket 35214 + * + * @covers ::create_initial_comment_types */ public function test_built_in_comment_types_are_registered() { $this->assertTrue( comment_type_exists( 'comment' ) ); diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php index 2dfa184cbd342..000fea0044698 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -37,26 +37,17 @@ public static function wpTearDownAfterClass() { } /** - * Ensures any comment type registered during a test is cleaned up. + * @ticket 35214 */ - public function tear_down() { - global $wp_comment_types; - - foreach ( array_keys( $wp_comment_types ) as $comment_type ) { - if ( ! $wp_comment_types[ $comment_type ]->_builtin ) { - unset( $wp_comment_types[ $comment_type ] ); - } - } - - parent::tear_down(); - } - public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/comment-types', $routes ); $this->assertArrayHasKey( '/wp/v2/comment-types/(?P[\w-]+)', $routes ); } + /** + * @ticket 35214 + */ public function test_context_param() { // Collection. $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); @@ -72,6 +63,9 @@ public function test_context_param() { $this->assertSameSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); } + /** + * @ticket 35214 + */ public function test_get_items() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); $response = rest_get_server()->dispatch( $request ); @@ -87,6 +81,8 @@ public function test_get_items() { /** * The internal `note` type is not exposed (show_in_rest defaults to public, which is false). + * + * @ticket 35214 */ public function test_get_items_excludes_non_rest_types() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); @@ -96,6 +92,9 @@ public function test_get_items_excludes_non_rest_types() { $this->assertArrayNotHasKey( 'note', $data ); } + /** + * @ticket 35214 + */ public function test_get_items_includes_registered_custom_type() { register_comment_type( 'review', array( 'public' => true ) ); @@ -106,6 +105,9 @@ public function test_get_items_includes_registered_custom_type() { $this->assertArrayHasKey( 'review', $data ); } + /** + * @ticket 35214 + */ public function test_get_items_excludes_custom_type_opted_out_of_rest() { register_comment_type( 'review', @@ -123,6 +125,8 @@ public function test_get_items_excludes_custom_type_opted_out_of_rest() { } /** + * @ticket 35214 + * * @dataProvider data_readable_http_methods * * @param string $method HTTP method to use. @@ -147,24 +151,40 @@ public static function data_readable_http_methods() { ); } + /** + * @ticket 35214 + */ public function test_get_item() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); $response = rest_get_server()->dispatch( $request ); $this->check_comment_type_object_response( 'view', $response ); } - public function test_get_item_invalid_type() { - $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/invalid' ); + /** + * @ticket 35214 + * + * @dataProvider data_readable_http_methods + * + * @param string $method HTTP method to use. + */ + public function test_get_item_invalid_type( $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/comment-types/invalid' ); $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_type_invalid', $response, 404 ); } + /** + * @ticket 35214 + */ public function test_get_item_non_rest_type_is_not_readable() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/note' ); $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_cannot_read_type', $response, 401 ); } + /** + * @ticket 35214 + */ public function test_get_item_edit_context_requires_permission() { wp_set_current_user( self::$subscriber_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); @@ -173,6 +193,104 @@ public function test_get_item_edit_context_requires_permission() { $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); } + /** + * An anonymous edit-context request is rejected with 401 before the type is resolved. + * + * This deliberately diverges from the post types controller, which resolves the type + * first and returns 404 for unknown types: here the permission callback runs before + * the route callback, so authentication is always demanded first. + * + * @ticket 35214 + */ + public function test_get_item_edit_context_as_anonymous_returns_401() { + wp_set_current_user( 0 ); + + // A known type. + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + + // An unknown type: still 401, not 404. + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/unknown' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + } + + /** + * @ticket 35214 + */ + public function test_get_item_with_fields_limits_response_keys() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( '_fields', 'slug' ); + $server = rest_get_server(); + $response = $server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertSame( array( 'slug' ), array_keys( $response->get_data() ) ); + } + + /** + * @ticket 35214 + */ + public function test_additional_field_registered_for_comment_type_is_included() { + register_rest_field( + 'comment-type', + 'my_custom_field', + array( + 'get_callback' => static function () { + return 'custom value'; + }, + 'schema' => array( 'type' => 'string' ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'my_custom_field', $data ); + $this->assertSame( 'custom value', $data['my_custom_field'] ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + + /** + * In the edit context, labels are returned for every exposed type, and the internal + * 'note' type stays hidden even from users who can moderate comments. + * + * @ticket 35214 + */ + public function test_get_items_edit_context_returns_labels_and_still_excludes_note() { + wp_set_current_user( self::$admin_id ); + register_comment_type( + 'review', + array( + 'label' => 'Reviews', + 'public' => true, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'review', $data ); + $this->assertArrayNotHasKey( 'note', $data ); + + foreach ( $data as $slug => $item ) { + $this->assertArrayHasKey( 'labels', $item, "Labels missing for '{$slug}' in edit context." ); + } + } + + /** + * @ticket 35214 + */ public function test_get_item_edit_context_returns_labels() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); @@ -181,6 +299,9 @@ public function test_get_item_edit_context_returns_labels() { $this->check_comment_type_object_response( 'edit', $response ); } + /** + * @ticket 35214 + */ public function test_create_item() { /** Comment types can't be created */ $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types' ); @@ -188,6 +309,9 @@ public function test_create_item() { $this->assertSame( 404, $response->get_status() ); } + /** + * @ticket 35214 + */ public function test_update_item() { /** Comment types can't be updated */ $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types/comment' ); @@ -195,6 +319,9 @@ public function test_update_item() { $this->assertSame( 404, $response->get_status() ); } + /** + * @ticket 35214 + */ public function test_delete_item() { /** Comment types can't be deleted */ $request = new WP_REST_Request( 'DELETE', '/wp/v2/comment-types/comment' ); @@ -202,6 +329,9 @@ public function test_delete_item() { $this->assertSame( 404, $response->get_status() ); } + /** + * @ticket 35214 + */ public function test_prepare_item() { $obj = get_comment_type_object( 'comment' ); $endpoint = new WP_REST_Comment_Types_Controller(); @@ -211,6 +341,9 @@ public function test_prepare_item() { $this->check_comment_type_obj( 'view', $obj, $response->get_data(), $response->get_links() ); } + /** + * @ticket 35214 + */ public function test_get_item_schema() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); $response = rest_get_server()->dispatch( $request ); From e06ca77e07e4642194554278623fbec3e001ee95 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:17:09 -0700 Subject: [PATCH 5/6] Coding Standards: Remove a double blank line in WP_Comment_Type::set_props(). --- src/wp-includes/class-wp-comment-type.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 75115eca712c9..d626edf9e05a1 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -217,7 +217,6 @@ public function set_props( $args ) { $args['show_in_rest'] = $args['public']; } - $args['name'] = $this->name; foreach ( $args as $property_name => $property_value ) { From 22c397588d3efda4e2daeebd67fc41a567668c8a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:17:09 -0700 Subject: [PATCH 6/6] REST API: Regenerate the wp-api.js fixture for the comment-types routes. tests/qunit/fixtures/wp-api-generated.js is written by test_build_wp_api_client_fixtures and diff-checked in CI; the new /wp/v2/comment-types routes must be reflected in the committed copy. --- tests/qunit/fixtures/wp-api-generated.js | 72 +++++++++++++++++++++--- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..77dff892703c1 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,13 +22,7 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": { - "application-passwords": { - "endpoints": { - "authorization": "http://example.org/wp-admin/authorize-application.php" - } - } - }, + "authentication": [], "routes": { "/": { "namespace": "", @@ -10823,6 +10817,70 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/comment-types": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/comment-types" + } + ] + } + }, + "/wp/v2/comment-types/(?P[\\w-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "type": { + "description": "An alphanumeric identifier for the comment type.", + "type": "string", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, "/wp/v2/search": { "namespace": "wp/v2", "methods": [