From 122ac4b6efca72311319d8ef775c6061183432c1 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 2 Feb 2026 17:07:31 +0000 Subject: [PATCH 01/34] initial --- src/wp-includes/abilities.php | 9 + .../class-wp-post-type-abilities.php | 1242 +++++++++++++++++ src/wp-includes/class-wp-post-type.php | 14 + src/wp-includes/post.php | 2 + 4 files changed, 1267 insertions(+) create mode 100644 src/wp-includes/abilities/class-wp-post-type-abilities.php diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4c6db1ed830e0..eb88b2163ac59 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,7 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-post-type-abilities.php'; /** * Registers the core ability categories. * @@ -30,6 +31,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'post', + array( + 'label' => __( 'Post' ), + 'description' => __( 'Abilities related to the creation and management of posts of all types.' ), + ) + ); } /** diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php new file mode 100644 index 0000000000000..f7e9ac89cd3ee --- /dev/null +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -0,0 +1,1242 @@ +show_in_abilities ?? false; + + if ( false === $show ) { + continue; + } + + $register_get = true === $show || ( is_array( $show ) && ! empty( $show['get'] ) ); + + if ( $register_get ) { + self::register_get_ability( $post_type_object ); + } + } + } + + /** + * Registers the get ability for a specific post type. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return void + */ + private static function register_get_ability( WP_Post_Type $post_type_object ): void { + $slug = $post_type_object->name; + $label = $post_type_object->labels->singular_name ?? $post_type_object->label; + $name = "core/post-type/{$slug}/get"; + + wp_register_ability( + $name, + array( + 'label' => sprintf( + /* translators: %s: Post type singular name. */ + __( 'Get %s' ), + $label + ), + 'description' => sprintf( + /* translators: %1$s: Post type singular name (lowercase), %2$s: Post type plural name (lowercase). */ + __( 'Retrieves a single %1$s by ID or queries multiple %2$s with optional filters.' ), + strtolower( $label ), + strtolower( $post_type_object->labels->name ?? $post_type_object->label ) + ), + 'category' => 'post', + 'input_schema' => self::build_get_input_schema( $post_type_object ), + 'output_schema' => self::build_get_output_schema( $post_type_object ), + 'execute_callback' => self::make_execute_get_callback( $post_type_object ), + 'permission_callback' => self::make_permission_get_callback( $post_type_object ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /* + * ------------------------------------------------------------------------- + * Schema Building + * ------------------------------------------------------------------------- + */ + + /** + * Builds the input schema for the get ability. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return array The JSON schema for input. + */ + private static function build_get_input_schema( WP_Post_Type $post_type_object ): array { + $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); + + $include_properties = array( + 'taxonomies' => array( + 'type' => 'boolean', + 'description' => __( 'Whether to include taxonomy terms in the response.' ), + 'default' => false, + ), + ); + + if ( post_type_supports( $post_type_object->name, 'custom-fields' ) ) { + $include_properties['meta'] = array( + 'type' => 'boolean', + 'description' => __( 'Whether to include post meta in the response.' ), + 'default' => false, + ); + } + + $include_schema = array( + 'type' => 'object', + 'description' => __( 'Additional data to include in the response.' ), + 'properties' => $include_properties, + 'additionalProperties' => false, + ); + + $query_properties = array( + 'tax' => self::build_query_group_schema( + __( 'Taxonomy query to filter posts by taxonomy terms.' ), + self::build_tax_clause_schema() + ), + 'meta' => self::build_query_group_schema( + __( 'Meta query to filter posts by post meta values.' ), + self::build_meta_clause_schema() + ), + 'date' => self::build_date_query_schema(), + ); + + // Single post retrieval by ID. + $by_id_schema = array( + 'type' => 'object', + 'description' => __( 'Retrieve a single post by its ID.' ), + 'required' => array( 'id' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Unique identifier for the post.' ), + 'minimum' => 1, + ), + 'include' => $include_schema, + ), + 'additionalProperties' => false, + ); + + // Multi-post query with filters. + $query_schema = array( + 'type' => 'object', + 'description' => __( 'Query multiple posts with optional filters.' ), + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'description' => __( 'Filter by post status.' ), + 'enum' => $statuses, + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter posts by.' ), + ), + 'author' => array( + 'type' => 'integer', + 'description' => __( 'Filter posts by author user ID.' ), + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of posts to return.' ), + 'minimum' => 1, + 'maximum' => 100, + 'default' => 10, + ), + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Page number for paginated results.' ), + 'minimum' => 1, + 'default' => 1, + ), + 'order' => array( + 'type' => 'object', + 'description' => __( 'Ordering parameters.' ), + 'properties' => array( + 'orderby' => array( + 'type' => 'string', + 'description' => __( 'Field to order results by.' ), + 'enum' => array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ), + 'default' => 'date', + ), + 'direction' => array( + 'type' => 'string', + 'description' => __( 'Order direction.' ), + 'enum' => array( 'asc', 'desc' ), + 'default' => 'desc', + ), + ), + 'additionalProperties' => false, + ), + 'query' => array( + 'type' => 'object', + 'description' => __( 'Advanced query filters for taxonomy terms, meta values, and dates.' ), + 'properties' => $query_properties, + 'additionalProperties' => false, + ), + 'include' => $include_schema, + ), + 'additionalProperties' => false, + ); + + return array( + 'oneOf' => array( + $by_id_schema, + $query_schema, + ), + ); + } + + /** + * Builds a query group schema with the recursive { relation, queries[] } structure. + * + * @since 7.0.0 + * + * @param string $description Description for the query group. + * @param array $leaf_schema JSON Schema for a leaf clause. + * @return array The JSON schema for the query group. + */ + private static function build_query_group_schema( string $description, array $leaf_schema ): array { + $nested_group_schema = array( + 'type' => 'object', + 'description' => __( 'Nested query group with its own relation.' ), + 'required' => array( 'queries' ), + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between nested clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'Nested query clauses.' ), + ), + ), + 'additionalProperties' => false, + ); + + return array( + 'type' => 'object', + 'description' => $description, + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between query clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'List of query clauses or nested groups.' ), + 'items' => array( + 'oneOf' => array( + $leaf_schema, + $nested_group_schema, + ), + ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the taxonomy clause leaf schema. + * + * @since 7.0.0 + * + * @return array The JSON schema for a taxonomy query clause. + */ + private static function build_tax_clause_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'taxonomy', 'terms' ), + 'properties' => array( + 'taxonomy' => array( + 'type' => 'string', + 'description' => __( 'Taxonomy slug to query.' ), + ), + 'terms' => array( + 'type' => 'array', + 'description' => __( 'Taxonomy terms to match.' ), + 'items' => array( + 'type' => array( 'integer', 'string' ), + ), + ), + 'field' => array( + 'type' => 'string', + 'description' => __( 'Term field to match against.' ), + 'enum' => array( 'term_id', 'slug', 'name', 'term_taxonomy_id' ), + ), + 'operator' => array( + 'type' => 'string', + 'description' => __( 'SQL operator to use for the query.' ), + 'enum' => array( 'IN', 'NOT IN', 'AND', 'EXISTS', 'NOT EXISTS' ), + ), + 'include_children' => array( + 'type' => 'boolean', + 'description' => __( 'Whether to include child terms. Only applicable for hierarchical taxonomies.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the meta clause leaf schema. + * + * @since 7.0.0 + * + * @return array The JSON schema for a meta query clause. + */ + private static function build_meta_clause_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'key' ), + 'properties' => array( + 'key' => array( + 'type' => 'string', + 'description' => __( 'Meta key to query.' ), + ), + 'value' => array( + 'type' => array( 'string', 'integer', 'array' ), + 'description' => __( 'Meta value to match. Use an array for BETWEEN, NOT BETWEEN, IN, and NOT IN comparisons.' ), + ), + 'compare' => array( + 'type' => 'string', + 'description' => __( 'Comparison operator.' ), + 'enum' => array( + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + 'REGEXP', + 'NOT REGEXP', + 'RLIKE', + ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'Cast the meta value to this type for comparison.' ), + 'enum' => array( + 'NUMERIC', + 'CHAR', + 'DATE', + 'DATETIME', + 'TIME', + 'BINARY', + 'SIGNED', + 'UNSIGNED', + 'DECIMAL', + ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the date query schema with the top-level column field. + * + * @since 7.0.0 + * + * @return array The JSON schema for a date query. + */ + private static function build_date_query_schema(): array { + $date_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ); + + $date_object_schema = array( + 'type' => 'object', + 'properties' => array( + 'year' => array( + 'type' => 'integer', + 'description' => __( 'Year.' ), + ), + 'month' => array( + 'type' => 'integer', + 'description' => __( 'Month.' ), + ), + 'day' => array( + 'type' => 'integer', + 'description' => __( 'Day.' ), + ), + ), + 'additionalProperties' => false, + ); + + $date_clause_schema = array( + 'type' => 'object', + 'properties' => array( + 'year' => array( + 'type' => 'integer', + 'description' => __( 'Four-digit year.' ), + ), + 'month' => array( + 'type' => 'integer', + 'description' => __( 'Month number (1-12).' ), + ), + 'week' => array( + 'type' => 'integer', + 'description' => __( 'Week of the year (0-53).' ), + ), + 'day' => array( + 'type' => 'integer', + 'description' => __( 'Day of the month (1-31).' ), + ), + 'hour' => array( + 'type' => 'integer', + 'description' => __( 'Hour (0-23).' ), + ), + 'minute' => array( + 'type' => 'integer', + 'description' => __( 'Minute (0-59).' ), + ), + 'second' => array( + 'type' => 'integer', + 'description' => __( 'Second (0-59).' ), + ), + 'dayofweek' => array( + 'type' => 'integer', + 'description' => __( 'Day of the week (1-7, Sunday is 1).' ), + ), + 'dayofweek_iso' => array( + 'type' => 'integer', + 'description' => __( 'ISO day of the week (1-7, Monday is 1).' ), + ), + 'dayofyear' => array( + 'type' => 'integer', + 'description' => __( 'Day of the year (1-366).' ), + ), + 'after' => array( + 'oneOf' => array( + array( + 'type' => 'string', + 'description' => __( 'Date string parseable by strtotime().' ), + ), + $date_object_schema, + ), + 'description' => __( 'Retrieve posts after this date.' ), + ), + 'before' => array( + 'oneOf' => array( + array( + 'type' => 'string', + 'description' => __( 'Date string parseable by strtotime().' ), + ), + $date_object_schema, + ), + 'description' => __( 'Retrieve posts before this date.' ), + ), + 'inclusive' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the after/before dates are inclusive.' ), + ), + 'compare' => array( + 'type' => 'string', + 'description' => __( 'Comparison operator.' ), + 'enum' => array( '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), + ), + 'column' => array( + 'type' => 'string', + 'description' => __( 'Database column to query against for this clause.' ), + 'enum' => $date_columns, + ), + ), + 'additionalProperties' => false, + ); + + $nested_group_schema = array( + 'type' => 'object', + 'description' => __( 'Nested date query group with its own relation.' ), + 'required' => array( 'queries' ), + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between nested clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'Nested date query clauses.' ), + ), + ), + 'additionalProperties' => false, + ); + + return array( + 'type' => 'object', + 'description' => __( 'Date query to filter posts by date fields.' ), + 'properties' => array( + 'relation' => array( + 'type' => 'string', + 'description' => __( 'Logical relation between date query clauses.' ), + 'enum' => array( 'AND', 'OR' ), + ), + 'column' => array( + 'type' => 'string', + 'description' => __( 'Default database column to query against.' ), + 'enum' => $date_columns, + ), + 'queries' => array( + 'type' => 'array', + 'description' => __( 'List of date query clauses or nested groups.' ), + 'items' => array( + 'oneOf' => array( + $date_clause_schema, + $nested_group_schema, + ), + ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the output schema for the get ability, based on post type supports. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return array The JSON schema for output. + */ + private static function build_get_output_schema( WP_Post_Type $post_type_object ): array { + $post_schema = self::build_post_schema( $post_type_object ); + + return array( + 'oneOf' => array( + $post_schema, + array( + 'type' => 'object', + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'List of posts matching the query.' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of pages.' ), + ), + ), + 'required' => array( 'posts', 'total', 'total_pages' ), + 'additionalProperties' => false, + ), + ), + ); + } + + /** + * Builds a single post object schema based on what the post type supports. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return array The JSON schema for a single post object. + */ + private static function build_post_schema( WP_Post_Type $post_type_object ): array { + $slug = $post_type_object->name; + + // Base fields that are always present regardless of supports. + $properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'The post publication date in ISO 8601 format.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( 'The post last modified date in ISO 8601 format.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The post slug.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink URL.' ), + ), + ); + + $required = array( 'id', 'type', 'status', 'date', 'modified', 'slug', 'link' ); + + // Conditional fields based on post type supports. + if ( post_type_supports( $slug, 'title' ) ) { + $properties['title'] = array( + 'type' => 'string', + 'description' => __( 'The post title.' ), + ); + $required[] = 'title'; + } + + if ( post_type_supports( $slug, 'editor' ) ) { + $properties['content'] = array( + 'type' => 'string', + 'description' => __( 'The post content.' ), + ); + $required[] = 'content'; + } + + if ( post_type_supports( $slug, 'excerpt' ) ) { + $properties['excerpt'] = array( + 'type' => 'string', + 'description' => __( 'The post excerpt.' ), + ); + $required[] = 'excerpt'; + } + + if ( post_type_supports( $slug, 'author' ) ) { + $properties['author'] = array( + 'type' => 'object', + 'description' => __( 'The post author.' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The author user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The author display name.' ), + ), + ), + 'required' => array( 'id', 'display_name' ), + 'additionalProperties' => false, + ); + $required[] = 'author'; + } + + if ( post_type_supports( $slug, 'thumbnail' ) ) { + $properties['featured_media'] = array( + 'type' => 'integer', + 'description' => __( 'The featured image attachment ID. 0 if no featured image is set.' ), + ); + $required[] = 'featured_media'; + } + + if ( post_type_supports( $slug, 'page-attributes' ) ) { + $properties['parent'] = array( + 'type' => 'integer', + 'description' => __( 'The parent post ID. 0 if no parent.' ), + ); + $properties['menu_order'] = array( + 'type' => 'integer', + 'description' => __( 'The order value for the post, used for sorting.' ), + ); + $required[] = 'parent'; + $required[] = 'menu_order'; + } + + if ( post_type_supports( $slug, 'post-formats' ) ) { + $properties['format'] = array( + 'type' => 'string', + 'description' => __( 'The post format.' ), + ); + $required[] = 'format'; + } + + if ( post_type_supports( $slug, 'comments' ) ) { + $properties['comment_status'] = array( + 'type' => 'string', + 'description' => __( 'Whether comments are allowed.' ), + 'enum' => array( 'open', 'closed' ), + ); + $required[] = 'comment_status'; + } + + if ( post_type_supports( $slug, 'trackbacks' ) ) { + $properties['ping_status'] = array( + 'type' => 'string', + 'description' => __( 'Whether trackbacks and pingbacks are allowed.' ), + 'enum' => array( 'open', 'closed' ), + ); + $required[] = 'ping_status'; + } + + return array( + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + 'additionalProperties' => false, + ); + } + + /* + * ------------------------------------------------------------------------- + * Execution + * ------------------------------------------------------------------------- + */ + + /** + * Creates the execute callback for a post type's get ability. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return Closure The execute callback. + */ + private static function make_execute_get_callback( WP_Post_Type $post_type_object ): Closure { + return static function ( $input = array() ) use ( $post_type_object ) { + $input = is_array( $input ) ? $input : array(); + + // Single post retrieval by ID. + if ( ! empty( $input['id'] ) ) { + return self::execute_get_single( (int) $input['id'], $post_type_object, $input ); + } + + // Multi-post query. + return self::execute_get_query( $post_type_object, $input ); + }; + } + + /** + * Creates the permission callback for a post type's get ability. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @return Closure The permission callback. + */ + private static function make_permission_get_callback( WP_Post_Type $post_type_object ): Closure { + return static function ( $input = array() ) use ( $post_type_object ): bool { + $input = is_array( $input ) ? $input : array(); + + // For single post retrieval, check specific post permission. + if ( ! empty( $input['id'] ) ) { + return current_user_can( 'read_post', (int) $input['id'] ); + } + + // For queries, check general read capability. + return current_user_can( $post_type_object->cap->read ?? 'read' ); + }; + } + + /** + * Retrieves a single post by ID. + * + * @since 7.0.0 + * + * @param int $post_id The post ID. + * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The input parameters. + * @return array|WP_Error Post data or error. + */ + private static function execute_get_single( int $post_id, WP_Post_Type $post_type_object, array $input ) { + $post = get_post( $post_id ); + + if ( ! $post || $post->post_type !== $post_type_object->name ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.' ), + array( 'status' => 404 ) + ); + } + + return self::format_post( $post, $post_type_object, $input ); + } + + /** + * Queries multiple posts. + * + * @since 7.0.0 + * + * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The input parameters. + * @return array Query results with posts, total, and total_pages. + */ + private static function execute_get_query( WP_Post_Type $post_type_object, array $input ): array { + $per_page = $input['per_page'] ?? 10; + $page = $input['page'] ?? 1; + + $query_args = array( + 'post_type' => $post_type_object->name, + 'post_status' => $input['status'] ?? 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'perm' => 'readable', + ); + + if ( ! empty( $input['search'] ) ) { + $query_args['s'] = sanitize_text_field( $input['search'] ); + } + + if ( ! empty( $input['author'] ) ) { + $query_args['author'] = (int) $input['author']; + } + + if ( ! empty( $input['order'] ) ) { + $order_input = $input['order']; + $orderby_map = array( + 'date' => 'date', + 'title' => 'title', + 'modified' => 'modified', + 'id' => 'ID', + 'author' => 'author', + 'relevance' => 'relevance', + ); + + if ( ! empty( $order_input['orderby'] ) && isset( $orderby_map[ $order_input['orderby'] ] ) ) { + $query_args['orderby'] = $orderby_map[ $order_input['orderby'] ]; + } + if ( ! empty( $order_input['direction'] ) ) { + $query_args['order'] = strtoupper( $order_input['direction'] ); + } + } + + // Process advanced query clauses. + if ( ! empty( $input['query'] ) ) { + $query_input = $input['query']; + + if ( ! empty( $query_input['tax'] ) ) { + $tax_query = self::process_query_recursive( + $query_input['tax'], + array( __CLASS__, 'process_tax_clause' ) + ); + if ( ! empty( $tax_query ) ) { + $query_args['tax_query'] = $tax_query; + } + } + + if ( ! empty( $query_input['meta'] ) ) { + $meta_query = self::process_query_recursive( + $query_input['meta'], + array( __CLASS__, 'process_meta_clause' ) + ); + if ( ! empty( $meta_query ) ) { + $query_args['meta_query'] = $meta_query; + } + } + + if ( ! empty( $query_input['date'] ) ) { + $date_query = self::process_query_recursive( + $query_input['date'], + array( __CLASS__, 'process_date_clause' ), + array( __CLASS__, 'process_date_top_level' ) + ); + if ( ! empty( $date_query ) ) { + $query_args['date_query'] = $date_query; + } + } + } + + $query = new WP_Query( $query_args ); + $posts = array(); + + foreach ( $query->posts as $post ) { + $posts[] = self::format_post( $post, $post_type_object, $input ); + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Formats a post object into the ability output format. + * + * Fields included depend on what the post type supports. + * + * @since 7.0.0 + * + * @param WP_Post $post The post object. + * @param WP_Post_Type $post_type_object The post type object. + * @param array $input The input parameters (for include flags). + * @return array Formatted post data. + */ + private static function format_post( WP_Post $post, WP_Post_Type $post_type_object, array $input ): array { + $slug = $post_type_object->name; + + // Base fields always present. + $data = array( + 'id' => $post->ID, + 'type' => $post->post_type, + 'status' => $post->post_status, + 'date' => mysql2date( 'c', $post->post_date_gmt ), + 'modified' => mysql2date( 'c', $post->post_modified_gmt ), + 'slug' => $post->post_name, + 'link' => get_permalink( $post ), + ); + + // Conditional fields based on post type supports. + if ( post_type_supports( $slug, 'title' ) ) { + $data['title'] = get_the_title( $post ); + } + + if ( post_type_supports( $slug, 'editor' ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $data['content'] = apply_filters( 'the_content', $post->post_content ); + } + + if ( post_type_supports( $slug, 'excerpt' ) ) { + $data['excerpt'] = get_the_excerpt( $post ); + } + + if ( post_type_supports( $slug, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'display_name' => $author ? $author->display_name : '', + ); + } + + if ( post_type_supports( $slug, 'thumbnail' ) ) { + $data['featured_media'] = (int) get_post_thumbnail_id( $post ); + } + + if ( post_type_supports( $slug, 'page-attributes' ) ) { + $data['parent'] = (int) $post->post_parent; + $data['menu_order'] = (int) $post->menu_order; + } + + if ( post_type_supports( $slug, 'post-formats' ) ) { + $format = get_post_format( $post ); + $data['format'] = $format ? $format : 'standard'; + } + + if ( post_type_supports( $slug, 'comments' ) ) { + $data['comment_status'] = $post->comment_status; + } + + if ( post_type_supports( $slug, 'trackbacks' ) ) { + $data['ping_status'] = $post->ping_status; + } + + // Include optional data based on include flags. + $include = $input['include'] ?? array(); + + if ( ! empty( $include['taxonomies'] ) ) { + $taxonomies = get_object_taxonomies( $post->post_type, 'objects' ); + $terms_data = array(); + + foreach ( $taxonomies as $taxonomy ) { + if ( ! $taxonomy->public ) { + continue; + } + $terms = get_the_terms( $post, $taxonomy->name ); + if ( $terms && ! is_wp_error( $terms ) ) { + $terms_data[ $taxonomy->name ] = array_map( + static function ( $term ): array { + return array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + }, + $terms + ); + } + } + + $data['taxonomies'] = $terms_data; + } + + if ( ! empty( $include['meta'] ) && post_type_supports( $slug, 'custom-fields' ) ) { + $meta = get_post_meta( $post->ID ); + $public_meta = array(); + + foreach ( $meta as $key => $values ) { + if ( is_protected_meta( $key, 'post' ) ) { + continue; + } + $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; + } + + $data['meta'] = $public_meta; + } + + return $data; + } + + /* + * ------------------------------------------------------------------------- + * Query Processing + * ------------------------------------------------------------------------- + */ + + /** + * Recursively converts a semantic { relation, queries[] } structure to a native WP query array. + * + * The semantic JSON format uses explicit `relation` and `queries` properties, + * while WP uses numeric-keyed arrays with a `relation` string key. + * + * @since 7.0.0 + * + * @param array $input The semantic query input. + * @param callable $process_leaf Callback to process a leaf clause. Receives an array, returns an array or null. + * @param callable|null $process_top_level Optional. Callback to handle top-level fields (e.g., date_query 'column'). + * Receives ($input, &$result). + * @return array The native WP query array. + */ + private static function process_query_recursive( array $input, callable $process_leaf, ?callable $process_top_level = null ): array { + $result = array(); + + if ( ! empty( $input['relation'] ) && in_array( $input['relation'], array( 'AND', 'OR' ), true ) ) { + $result['relation'] = $input['relation']; + } + + if ( $process_top_level ) { + $process_top_level( $input, $result ); + } + + if ( ! empty( $input['queries'] ) && is_array( $input['queries'] ) ) { + foreach ( $input['queries'] as $query ) { + if ( ! is_array( $query ) ) { + continue; + } + + if ( isset( $query['queries'] ) ) { + // Nested group: recurse. + $nested = self::process_query_recursive( $query, $process_leaf, $process_top_level ); + if ( ! empty( $nested ) ) { + $result[] = $nested; + } + } else { + // Leaf clause: process with type-specific callback. + $clause = $process_leaf( $query ); + if ( null !== $clause ) { + $result[] = $clause; + } + } + } + } + + return $result; + } + + /** + * Processes a taxonomy query leaf clause. + * + * @since 7.0.0 + * + * @param array $clause The raw clause data. + * @return array|null The processed clause or null if invalid. + */ + private static function process_tax_clause( array $clause ): ?array { + if ( empty( $clause['taxonomy'] ) || empty( $clause['terms'] ) ) { + return null; + } + + $taxonomy = sanitize_key( $clause['taxonomy'] ); + if ( ! taxonomy_exists( $taxonomy ) ) { + return null; + } + + $result = array( + 'taxonomy' => $taxonomy, + 'terms' => (array) $clause['terms'], + ); + + $allowed_fields = array( 'term_id', 'slug', 'name', 'term_taxonomy_id' ); + if ( ! empty( $clause['field'] ) && in_array( $clause['field'], $allowed_fields, true ) ) { + $result['field'] = $clause['field']; + } + + $allowed_operators = array( 'IN', 'NOT IN', 'AND', 'EXISTS', 'NOT EXISTS' ); + if ( ! empty( $clause['operator'] ) && in_array( $clause['operator'], $allowed_operators, true ) ) { + $result['operator'] = $clause['operator']; + } + + if ( isset( $clause['include_children'] ) ) { + $result['include_children'] = (bool) $clause['include_children']; + } + + return $result; + } + + /** + * Processes a meta query leaf clause. + * + * @since 7.0.0 + * + * @param array $clause The raw clause data. + * @return array|null The processed clause or null if invalid. + */ + private static function process_meta_clause( array $clause ): ?array { + if ( empty( $clause['key'] ) ) { + return null; + } + + $result = array( + 'key' => sanitize_key( $clause['key'] ), + ); + + if ( isset( $clause['value'] ) ) { + $result['value'] = $clause['value']; + } + + $allowed_compare = array( + '=', '!=', '>', '>=', '<', '<=', + 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', + 'BETWEEN', 'NOT BETWEEN', + 'EXISTS', 'NOT EXISTS', + 'REGEXP', 'NOT REGEXP', 'RLIKE', + ); + if ( ! empty( $clause['compare'] ) && in_array( $clause['compare'], $allowed_compare, true ) ) { + $result['compare'] = $clause['compare']; + } + + $allowed_types = array( + 'NUMERIC', 'CHAR', 'DATE', 'DATETIME', + 'TIME', 'BINARY', 'SIGNED', 'UNSIGNED', 'DECIMAL', + ); + if ( ! empty( $clause['type'] ) && in_array( $clause['type'], $allowed_types, true ) ) { + $result['type'] = $clause['type']; + } + + return $result; + } + + /** + * Processes a date query leaf clause. + * + * @since 7.0.0 + * + * @param array $clause The raw clause data. + * @return array|null The processed clause or null if invalid. + */ + private static function process_date_clause( array $clause ): ?array { + $result = array(); + + $int_fields = array( + 'year', 'month', 'week', 'day', + 'hour', 'minute', 'second', + 'dayofweek', 'dayofweek_iso', 'dayofyear', + ); + + foreach ( $int_fields as $field ) { + if ( isset( $clause[ $field ] ) ) { + $result[ $field ] = (int) $clause[ $field ]; + } + } + + // Handle after/before as string or { year, month, day } object. + foreach ( array( 'after', 'before' ) as $boundary ) { + if ( isset( $clause[ $boundary ] ) ) { + if ( is_string( $clause[ $boundary ] ) ) { + $result[ $boundary ] = sanitize_text_field( $clause[ $boundary ] ); + } elseif ( is_array( $clause[ $boundary ] ) ) { + $date_parts = array(); + foreach ( array( 'year', 'month', 'day' ) as $part ) { + if ( isset( $clause[ $boundary ][ $part ] ) ) { + $date_parts[ $part ] = (int) $clause[ $boundary ][ $part ]; + } + } + if ( ! empty( $date_parts ) ) { + $result[ $boundary ] = $date_parts; + } + } + } + } + + if ( isset( $clause['inclusive'] ) ) { + $result['inclusive'] = (bool) $clause['inclusive']; + } + + $allowed_compare = array( '=', '!=', '>', '>=', '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ); + if ( ! empty( $clause['compare'] ) && in_array( $clause['compare'], $allowed_compare, true ) ) { + $result['compare'] = $clause['compare']; + } + + $allowed_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ); + if ( ! empty( $clause['column'] ) && in_array( $clause['column'], $allowed_columns, true ) ) { + $result['column'] = $clause['column']; + } + + return ! empty( $result ) ? $result : null; + } + + /** + * Processes top-level date query fields. + * + * Handles the `column` field that applies as the default for all date clauses. + * + * @since 7.0.0 + * + * @param array $input The date query input. + * @param array $result The result array (passed by reference). + * @return void + */ + private static function process_date_top_level( array $input, array &$result ): void { + $allowed_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' ); + if ( ! empty( $input['column'] ) && in_array( $input['column'], $allowed_columns, true ) ) { + $result['column'] = $input['column']; + } + } +} diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index b53a244d7de84..5911bf660cf23 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -371,6 +371,19 @@ final class WP_Post_Type { */ public $show_in_rest; + /** + * Whether to register abilities for this post type via the Abilities API. + * + * Can be a boolean or an array of ability names mapped to booleans. + * - If true, all supported abilities are registered (currently just 'get'). + * - If false (default), no abilities are registered. + * - If an array, selectively enable abilities: e.g. array( 'get' => true ). + * + * @since 7.0.0 + * @var bool|array $show_in_abilities + */ + public $show_in_abilities; + /** * The base path for this post type's REST API endpoints. * @@ -551,6 +564,7 @@ public function set_props( $args ) { 'can_export' => true, 'delete_with_user' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, 'rest_base' => false, 'rest_namespace' => false, 'rest_controller_class' => false, diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index eefdaafb0f10d..c7a1d71b51f99 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -50,6 +50,7 @@ function create_initial_post_types() { 'post-formats', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'posts', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) @@ -84,6 +85,7 @@ function create_initial_post_types() { 'revisions', ), 'show_in_rest' => true, + 'show_in_abilities' => true, 'rest_base' => 'pages', 'rest_controller_class' => 'WP_REST_Posts_Controller', ) From 415c8af4e13809118a98903afe69aee68ff2f495 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 2 Feb 2026 18:07:52 +0000 Subject: [PATCH 02/34] remove defaults to be valid schema --- .../abilities/class-wp-post-type-abilities.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index f7e9ac89cd3ee..7c9c3118d0441 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -122,7 +122,6 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'taxonomies' => array( 'type' => 'boolean', 'description' => __( 'Whether to include taxonomy terms in the response.' ), - 'default' => false, ), ); @@ -130,7 +129,6 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) $include_properties['meta'] = array( 'type' => 'boolean', 'description' => __( 'Whether to include post meta in the response.' ), - 'default' => false, ); } @@ -190,16 +188,14 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) ), 'per_page' => array( 'type' => 'integer', - 'description' => __( 'Maximum number of posts to return.' ), + 'description' => __( 'Maximum number of posts to return. Defaults to 10.' ), 'minimum' => 1, 'maximum' => 100, - 'default' => 10, ), 'page' => array( 'type' => 'integer', - 'description' => __( 'Page number for paginated results.' ), + 'description' => __( 'Page number for paginated results. Defaults to 1.' ), 'minimum' => 1, - 'default' => 1, ), 'order' => array( 'type' => 'object', @@ -207,15 +203,13 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'properties' => array( 'orderby' => array( 'type' => 'string', - 'description' => __( 'Field to order results by.' ), + 'description' => __( 'Field to order results by. Defaults to date.' ), 'enum' => array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ), - 'default' => 'date', ), 'direction' => array( 'type' => 'string', - 'description' => __( 'Order direction.' ), + 'description' => __( 'Order direction. Defaults to desc.' ), 'enum' => array( 'asc', 'desc' ), - 'default' => 'desc', ), ), 'additionalProperties' => false, From 5f880ebf029a18a74616488ef57dcc1604e6f49d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 2 Feb 2026 18:18:19 +0000 Subject: [PATCH 03/34] fix output schemas --- .../class-wp-post-type-abilities.php | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 7c9c3118d0441..1449127afdcbf 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -722,6 +722,46 @@ private static function build_post_schema( WP_Post_Type $post_type_object ): arr $required[] = 'ping_status'; } + // Optional fields included when requested via `include` input flags. + $term_schema = array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The term ID.' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => __( 'The term name.' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => __( 'The term slug.' ), + ), + ), + 'required' => array( 'id', 'name', 'slug' ), + 'additionalProperties' => false, + ); + + $properties['taxonomies'] = array( + 'type' => 'object', + 'description' => __( 'Taxonomy terms grouped by taxonomy name. Only present when include.taxonomies is true.' ), + 'additionalProperties' => array( + 'type' => 'array', + 'items' => $term_schema, + ), + ); + + if ( post_type_supports( $slug, 'custom-fields' ) ) { + $properties['meta'] = array( + 'type' => 'object', + 'description' => __( 'Public post meta key-value pairs. Only present when include.meta is true.' ), + 'additionalProperties' => array( + 'type' => array( 'string', 'array' ), + ), + ); + } + return array( 'type' => 'object', 'properties' => $properties, @@ -998,7 +1038,7 @@ static function ( $term ): array { } } - $data['taxonomies'] = $terms_data; + $data['taxonomies'] = ! empty( $terms_data ) ? $terms_data : new stdClass(); } if ( ! empty( $include['meta'] ) && post_type_supports( $slug, 'custom-fields' ) ) { @@ -1012,7 +1052,7 @@ static function ( $term ): array { $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; } - $data['meta'] = $public_meta; + $data['meta'] = ! empty( $public_meta ) ? $public_meta : new stdClass(); } return $data; From ac35e948aa6dcf4aba285dad6d8c5f6fa2a0aa7e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 3 Feb 2026 20:48:37 +0000 Subject: [PATCH 04/34] multiple enhacemtns: empty input, additional filters --- .../class-wp-post-type-abilities.php | 192 +++++++++++------- 1 file changed, 113 insertions(+), 79 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 1449127afdcbf..921052c3556ce 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -116,6 +116,7 @@ private static function register_get_ability( WP_Post_Type $post_type_object ): * @return array The JSON schema for input. */ private static function build_get_input_schema( WP_Post_Type $post_type_object ): array { + $slug = $post_type_object->name; $statuses = array_values( get_post_stati( array( 'internal' => false ) ) ); $include_properties = array( @@ -125,7 +126,7 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) ), ); - if ( post_type_supports( $post_type_object->name, 'custom-fields' ) ) { + if ( post_type_supports( $slug, 'custom-fields' ) ) { $include_properties['meta'] = array( 'type' => 'boolean', 'description' => __( 'Whether to include post meta in the response.' ), @@ -151,85 +152,104 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'date' => self::build_date_query_schema(), ); - // Single post retrieval by ID. - $by_id_schema = array( - 'type' => 'object', - 'description' => __( 'Retrieve a single post by its ID.' ), - 'required' => array( 'id' ), - 'properties' => array( - 'id' => array( - 'type' => 'integer', - 'description' => __( 'Unique identifier for the post.' ), - 'minimum' => 1, - ), - 'include' => $include_schema, - ), - 'additionalProperties' => false, - ); + // Build orderby enum dynamically based on post type supports. + $orderby_values = array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ); + if ( post_type_supports( $slug, 'page-attributes' ) ) { + $orderby_values[] = 'menu_order'; + } + if ( post_type_supports( $slug, 'comments' ) ) { + $orderby_values[] = 'comment_count'; + } - // Multi-post query with filters. - $query_schema = array( - 'type' => 'object', - 'description' => __( 'Query multiple posts with optional filters.' ), - 'properties' => array( - 'status' => array( - 'type' => 'string', - 'description' => __( 'Filter by post status.' ), - 'enum' => $statuses, - ), - 'search' => array( - 'type' => 'string', - 'description' => __( 'Search term to filter posts by.' ), - ), - 'author' => array( - 'type' => 'integer', - 'description' => __( 'Filter posts by author user ID.' ), - 'minimum' => 1, - ), - 'per_page' => array( - 'type' => 'integer', - 'description' => __( 'Maximum number of posts to return. Defaults to 10.' ), - 'minimum' => 1, - 'maximum' => 100, - ), - 'page' => array( - 'type' => 'integer', - 'description' => __( 'Page number for paginated results. Defaults to 1.' ), - 'minimum' => 1, - ), - 'order' => array( - 'type' => 'object', - 'description' => __( 'Ordering parameters.' ), - 'properties' => array( - 'orderby' => array( - 'type' => 'string', - 'description' => __( 'Field to order results by. Defaults to date.' ), - 'enum' => array( 'date', 'title', 'modified', 'id', 'author', 'relevance' ), - ), - 'direction' => array( - 'type' => 'string', - 'description' => __( 'Order direction. Defaults to desc.' ), - 'enum' => array( 'asc', 'desc' ), - ), + // All properties are optional. When `id` is present, single-post mode. + // When absent, query mode. Empty input returns latest published posts. + $properties = array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Unique identifier for the post. When provided, retrieves a single post by ID.' ), + 'minimum' => 1, + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'Filter by post status.' ), + 'enum' => $statuses, + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter posts by.' ), + ), + 'author' => array( + 'type' => 'integer', + 'description' => __( 'Filter posts by author user ID.' ), + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => __( 'Maximum number of posts to return. Defaults to 10.' ), + 'minimum' => 1, + 'maximum' => 100, + ), + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Page number for paginated results. Defaults to 1.' ), + 'minimum' => 1, + ), + 'order' => array( + 'type' => 'object', + 'description' => __( 'Ordering parameters.' ), + 'properties' => array( + 'orderby' => array( + 'type' => 'string', + 'description' => __( 'Field to order results by. Defaults to date.' ), + 'enum' => $orderby_values, + ), + 'direction' => array( + 'type' => 'string', + 'description' => __( 'Order direction. Defaults to desc.' ), + 'enum' => array( 'asc', 'desc' ), ), - 'additionalProperties' => false, - ), - 'query' => array( - 'type' => 'object', - 'description' => __( 'Advanced query filters for taxonomy terms, meta values, and dates.' ), - 'properties' => $query_properties, - 'additionalProperties' => false, ), - 'include' => $include_schema, + 'additionalProperties' => false, ), - 'additionalProperties' => false, + 'query' => array( + 'type' => 'object', + 'description' => __( 'Advanced query filters for taxonomy terms, meta values, and dates.' ), + 'properties' => $query_properties, + 'additionalProperties' => false, + ), + 'include' => $include_schema, ); + // Supports-dependent filter properties. + if ( post_type_supports( $slug, 'comments' ) ) { + $properties['comment_status'] = array( + 'type' => 'string', + 'description' => __( 'Filter by comment status.' ), + 'enum' => array( 'open', 'closed' ), + ); + } + + if ( post_type_supports( $slug, 'trackbacks' ) ) { + $properties['ping_status'] = array( + 'type' => 'string', + 'description' => __( 'Filter by ping status.' ), + 'enum' => array( 'open', 'closed' ), + ); + } + + if ( $post_type_object->hierarchical ) { + $properties['parent'] = array( + 'type' => 'integer', + 'description' => __( 'Filter by parent post ID. Use 0 for top-level posts.' ), + 'minimum' => 0, + ); + } + return array( - 'oneOf' => array( - $by_id_schema, - $query_schema, - ), + 'type' => 'object', + 'properties' => $properties, + 'additionalProperties' => false, + 'default' => array(), ); } @@ -873,15 +893,29 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array $query_args['author'] = (int) $input['author']; } + if ( ! empty( $input['comment_status'] ) ) { + $query_args['comment_status'] = sanitize_key( $input['comment_status'] ); + } + + if ( ! empty( $input['ping_status'] ) ) { + $query_args['ping_status'] = sanitize_key( $input['ping_status'] ); + } + + if ( isset( $input['parent'] ) ) { + $query_args['post_parent'] = (int) $input['parent']; + } + if ( ! empty( $input['order'] ) ) { $order_input = $input['order']; $orderby_map = array( - 'date' => 'date', - 'title' => 'title', - 'modified' => 'modified', - 'id' => 'ID', - 'author' => 'author', - 'relevance' => 'relevance', + 'date' => 'date', + 'title' => 'title', + 'modified' => 'modified', + 'id' => 'ID', + 'author' => 'author', + 'relevance' => 'relevance', + 'menu_order' => 'menu_order', + 'comment_count' => 'comment_count', ); if ( ! empty( $order_input['orderby'] ) && isset( $orderby_map[ $order_input['orderby'] ] ) ) { From 088487b032f8ae47cc98fb3abc815d1e336964a5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 3 Feb 2026 21:06:20 +0000 Subject: [PATCH 05/34] fix 404 return when id does not exist --- .../abilities/class-wp-post-type-abilities.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 921052c3556ce..db813ec3e2be3 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -831,8 +831,16 @@ private static function make_permission_get_callback( WP_Post_Type $post_type_ob $input = is_array( $input ) ? $input : array(); // For single post retrieval, check specific post permission. + // If the post doesn't exist, verify the user has general read + // capability before letting the execute callback return a 404. if ( ! empty( $input['id'] ) ) { - return current_user_can( 'read_post', (int) $input['id'] ); + $post = get_post( (int) $input['id'] ); + + if ( ! $post || $post->post_type !== $post_type_object->name ) { + return current_user_can( $post_type_object->cap->read_others_posts ?? 'read' ); + } + + return current_user_can( 'read_post', $post->ID ); } // For queries, check general read capability. From 513db78771813e301816d7f5b97c298f56b0531b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 3 Feb 2026 21:06:30 +0000 Subject: [PATCH 06/34] add unit tests --- .../abilities-api/wpPostTypeAbilitiesRest.php | 509 ++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php new file mode 100644 index 0000000000000..6ce20a1e2e2d8 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -0,0 +1,509 @@ +user->create( array( 'role' => 'editor' ) ); + + // Create tags. + foreach ( array( 't-23', 'c-1', 'b-1', 'other-tag' ) as $slug ) { + self::$tag_ids[ $slug ] = $factory->term->create( + array( + 'taxonomy' => 'post_tag', + 'slug' => $slug, + 'name' => $slug, + ) + ); + } + + // Post 1: has footnotes meta, a=23, b=1, tags t-23 & c-1, date 2025-11-26. + self::$post_ids[1] = $factory->post->create( + array( + 'post_title' => 'Post with footnotes', + 'post_status' => 'publish', + 'post_date' => '2025-11-26 10:00:00', + ) + ); + update_post_meta( self::$post_ids[1], 'footnotes', '[{}]' ); + update_post_meta( self::$post_ids[1], 'a', '23' ); + update_post_meta( self::$post_ids[1], 'b', '1' ); + wp_set_object_terms( self::$post_ids[1], array( 't-23', 'c-1' ), 'post_tag' ); + + // Post 2: a=23, c=1, tags t-23 & b-1, date 2025-11-15. + self::$post_ids[2] = $factory->post->create( + array( + 'post_title' => 'Post with meta a and c', + 'post_status' => 'publish', + 'post_date' => '2025-11-15 10:00:00', + ) + ); + update_post_meta( self::$post_ids[2], 'a', '23' ); + update_post_meta( self::$post_ids[2], 'c', '1' ); + wp_set_object_terms( self::$post_ids[2], array( 't-23', 'b-1' ), 'post_tag' ); + + // Post 3: b=1, tag b-1, date 2025-06-26. + self::$post_ids[3] = $factory->post->create( + array( + 'post_title' => 'Post with meta b only', + 'post_status' => 'publish', + 'post_date' => '2025-06-26 10:00:00', + ) + ); + update_post_meta( self::$post_ids[3], 'b', '1' ); + wp_set_object_terms( self::$post_ids[3], array( 'b-1' ), 'post_tag' ); + + // Post 4: x=99, tag other-tag, date 2024-03-10. + self::$post_ids[4] = $factory->post->create( + array( + 'post_title' => 'Post with unrelated meta', + 'post_status' => 'publish', + 'post_date' => '2024-03-10 10:00:00', + ) + ); + update_post_meta( self::$post_ids[4], 'x', '99' ); + wp_set_object_terms( self::$post_ids[4], array( 'other-tag' ), 'post_tag' ); + + // Post 5: no meta, no tags, date 2025-11-01. + self::$post_ids[5] = $factory->post->create( + array( + 'post_title' => 'Post for date nov', + 'post_status' => 'publish', + 'post_date' => '2025-11-01 10:00:00', + ) + ); + + // Post 6: no meta, no tags, date 2025-06-26. + self::$post_ids[6] = $factory->post->create( + array( + 'post_title' => 'Post for date day26', + 'post_status' => 'publish', + 'post_date' => '2025-06-26 10:00:00', + ) + ); + } + + /** + * Clean up after all tests. + */ + public static function wpTearDownAfterClass(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $category ) { + wp_unregister_ability_category( $category->get_slug() ); + } + } + + /** + * Set up each test with an authenticated editor user. + */ + public function set_up(): void { + parent::set_up(); + wp_set_current_user( self::$editor_id ); + } + + /** + * Dispatches a GET request to the post type get ability endpoint. + * + * @param array $input Input parameters for the ability. + * @return WP_REST_Response The response. + */ + private function dispatch_get_ability( array $input = array() ): WP_REST_Response { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + if ( ! empty( $input ) ) { + $request->set_query_params( array( 'input' => $input ) ); + } + return rest_get_server()->dispatch( $request ); + } + + /** + * Extracts post IDs from a query response's posts array. + * + * @param array $data Response data containing 'posts' key. + * @return int[] Array of post IDs. + */ + private function get_response_post_ids( array $data ): array { + return array_map( + static function ( $post ) { + return $post['id']; + }, + $data['posts'] + ); + } + + /** + * Tests that the ability run route is registered. + */ + public function test_route_is_registered(): void { + $routes = rest_get_server()->get_routes(); + // The route pattern covers all ability names including this one. + $this->assertArrayHasKey( + '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', + $routes + ); + } + + /** + * Tests that POST method is rejected for this readonly ability. + */ + public function test_post_method_rejected(): void { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + } + + /** + * Tests retrieving a single post by ID. + */ + public function test_get_single_post_by_id(): void { + $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( self::$post_ids[1], $data['id'] ); + $this->assertSame( 'post', $data['type'] ); + $this->assertSame( 'publish', $data['status'] ); + $this->assertSame( 'Post with footnotes', $data['title'] ); + $this->assertArrayHasKey( 'slug', $data ); + $this->assertArrayHasKey( 'link', $data ); + $this->assertArrayHasKey( 'date', $data ); + $this->assertArrayHasKey( 'modified', $data ); + } + + /** + * Tests retrieving a single post with meta and taxonomies included. + */ + public function test_get_single_post_with_meta_and_taxonomies(): void { + $response = $this->dispatch_get_ability( + array( + 'id' => self::$post_ids[1], + 'include' => array( + 'meta' => true, + 'taxonomies' => true, + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // Meta should contain the public meta keys. + $this->assertArrayHasKey( 'meta', $data ); + $this->assertArrayHasKey( 'footnotes', $data['meta'] ); + $this->assertArrayHasKey( 'a', $data['meta'] ); + $this->assertArrayHasKey( 'b', $data['meta'] ); + $this->assertSame( '23', $data['meta']['a'] ); + + // Taxonomies should contain post_tag terms. + $this->assertArrayHasKey( 'taxonomies', $data ); + $this->assertArrayHasKey( 'post_tag', $data['taxonomies'] ); + $tag_slugs = array_column( $data['taxonomies']['post_tag'], 'slug' ); + $this->assertContains( 't-23', $tag_slugs ); + $this->assertContains( 'c-1', $tag_slugs ); + } + + /** + * Tests that requesting a non-existent post returns 404. + */ + public function test_get_single_post_not_found(): void { + $response = $this->dispatch_get_ability( array( 'id' => 999999 ) ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Tests that query mode returns paginated results. + */ + public function test_query_returns_paginated_results(): void { + $response = $this->dispatch_get_ability( + array( + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertCount( 2, $data['posts'] ); + $this->assertSame( 6, $data['total'] ); + $this->assertSame( 3, $data['total_pages'] ); + } + + /** + * Tests meta query with EXISTS operator finds only the post with footnotes. + */ + public function test_meta_query_exists(): void { + $response = $this->dispatch_get_ability( + array( + 'include' => array( 'meta' => true ), + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'footnotes', + 'compare' => 'EXISTS', + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 1, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + } + + /** + * Tests nested meta query: a=23 AND (b=1 OR c=1). + * + * Should match posts 1 and 2. + */ + public function test_meta_query_nested_and_or(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'relation' => 'AND', + 'queries' => array( + array( + 'key' => 'a', + 'compare' => '=', + 'value' => '23', + ), + array( + 'relation' => 'OR', + 'queries' => array( + array( + 'key' => 'b', + 'compare' => '=', + 'value' => '1', + ), + array( + 'key' => 'c', + 'compare' => '=', + 'value' => '1', + ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 2, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + $this->assertContains( self::$post_ids[2], $post_ids ); + } + + /** + * Tests nested tax query: tag t-23 AND (tag c-1 OR tag b-1). + * + * Should match posts 1 and 2. + */ + public function test_tax_query_nested_and_or(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'tax' => array( + 'relation' => 'AND', + 'queries' => array( + array( + 'taxonomy' => 'post_tag', + 'field' => 'slug', + 'terms' => array( 't-23' ), + ), + array( + 'relation' => 'OR', + 'queries' => array( + array( + 'taxonomy' => 'post_tag', + 'field' => 'slug', + 'terms' => array( 'c-1' ), + ), + array( + 'taxonomy' => 'post_tag', + 'field' => 'slug', + 'terms' => array( 'b-1' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 2, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + $this->assertContains( self::$post_ids[2], $post_ids ); + } + + /** + * Tests nested date query: year=2025 AND (day=26 OR month=11). + * + * Should match posts 1, 2, 3, 5, 6 (all 2025 posts that have day=26 or month=11). + * Post 4 excluded because it is from 2024. + */ + public function test_date_query_nested_and_or(): void { + $response = $this->dispatch_get_ability( + array( + 'per_page' => 100, + 'query' => array( + 'date' => array( + 'relation' => 'AND', + 'queries' => array( + array( 'year' => 2025 ), + array( + 'relation' => 'OR', + 'queries' => array( + array( 'day' => 26 ), + array( 'month' => 11 ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 5, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + $this->assertContains( self::$post_ids[2], $post_ids ); + $this->assertContains( self::$post_ids[3], $post_ids ); + $this->assertContains( self::$post_ids[5], $post_ids ); + $this->assertContains( self::$post_ids[6], $post_ids ); + $this->assertNotContains( self::$post_ids[4], $post_ids ); + } + + /** + * Tests that unauthenticated requests are rejected. + */ + public function test_unauthenticated_query_rejected(): void { + wp_set_current_user( 0 ); + + $response = $this->dispatch_get_ability( array() ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + } + + /** + * Tests that authenticated editor can query posts. + */ + public function test_authenticated_query_succeeds(): void { + $response = $this->dispatch_get_ability( array() ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'posts', $data ); + $this->assertGreaterThan( 0, $data['total'] ); + } + + /** + * Tests ordering by title ascending. + */ + public function test_query_with_ordering(): void { + $response = $this->dispatch_get_ability( + array( + 'order' => array( + 'orderby' => 'title', + 'direction' => 'asc', + ), + 'per_page' => 100, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $titles = array_map( + static function ( $post ) { + return $post['title']; + }, + $data['posts'] + ); + + $sorted = $titles; + sort( $sorted, SORT_STRING ); + $this->assertSame( $sorted, $titles ); + } +} From 5d0c744c5ec2cce92e100e3196ec6daedbf713d5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 5 Feb 2026 16:13:22 +0000 Subject: [PATCH 07/34] lint fixes --- src/wp-includes/abilities.php | 1 + .../class-wp-post-type-abilities.php | 207 ++++++++++++++-- src/wp-includes/meta.php | 38 +-- .../abilities-api/wpPostTypeAbilitiesRest.php | 227 +++++++++++++++++- 4 files changed, 434 insertions(+), 39 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index eb88b2163ac59..b00b0d19de3fa 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -266,4 +266,5 @@ function wp_register_core_abilities(): void { ), ) ); + WP_Post_Type_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index db813ec3e2be3..2cd15035f89b9 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -879,9 +879,9 @@ private static function execute_get_single( int $post_id, WP_Post_Type $post_typ * * @param WP_Post_Type $post_type_object The post type object. * @param array $input The input parameters. - * @return array Query results with posts, total, and total_pages. + * @return array|WP_Error Query results with posts, total, and total_pages, or error. */ - private static function execute_get_query( WP_Post_Type $post_type_object, array $input ): array { + private static function execute_get_query( WP_Post_Type $post_type_object, array $input ) { $per_page = $input['per_page'] ?? 10; $page = $input['page'] ?? 1; @@ -939,6 +939,23 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array $query_input = $input['query']; if ( ! empty( $query_input['tax'] ) ) { + // Validate that all taxonomies in the query are public. + $taxonomies_in_query = self::extract_taxonomies_from_query( $query_input['tax'] ); + $allowed_taxonomies = self::get_allowed_taxonomies( $post_type_object->name ); + $invalid_taxonomies = array_diff( $taxonomies_in_query, $allowed_taxonomies ); + + if ( ! empty( $invalid_taxonomies ) ) { + return new WP_Error( + 'invalid_taxonomy', + sprintf( + /* translators: %s: Comma-separated list of invalid taxonomy slugs. */ + __( 'The following taxonomies are not allowed: %s' ), + implode( ', ', $invalid_taxonomies ) + ), + array( 'status' => 400 ) + ); + } + $tax_query = self::process_query_recursive( $query_input['tax'], array( __CLASS__, 'process_tax_clause' ) @@ -949,6 +966,23 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array } if ( ! empty( $query_input['meta'] ) ) { + // Validate that all meta keys in the query have show_in_abilities enabled. + $meta_keys_in_query = self::extract_meta_keys_from_query( $query_input['meta'] ); + $allowed_meta_keys = self::get_allowed_meta_keys( $post_type_object->name ); + $invalid_keys = array_diff( $meta_keys_in_query, $allowed_meta_keys ); + + if ( ! empty( $invalid_keys ) ) { + return new WP_Error( + 'invalid_meta_key', + sprintf( + /* translators: %s: Comma-separated list of invalid meta keys. */ + __( 'The following meta keys are not allowed: %s' ), + implode( ', ', $invalid_keys ) + ), + array( 'status' => 400 ) + ); + } + $meta_query = self::process_query_recursive( $query_input['meta'], array( __CLASS__, 'process_meta_clause' ) @@ -1025,8 +1059,8 @@ private static function format_post( WP_Post $post, WP_Post_Type $post_type_obje } if ( post_type_supports( $slug, 'author' ) ) { - $author = get_userdata( (int) $post->post_author ); - $data['author'] = array( + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( 'id' => (int) $post->post_author, 'display_name' => $author ? $author->display_name : '', ); @@ -1042,8 +1076,8 @@ private static function format_post( WP_Post $post, WP_Post_Type $post_type_obje } if ( post_type_supports( $slug, 'post-formats' ) ) { - $format = get_post_format( $post ); - $data['format'] = $format ? $format : 'standard'; + $format = get_post_format( $post ); + $data['format'] = $format ? $format : 'standard'; } if ( post_type_supports( $slug, 'comments' ) ) { @@ -1084,13 +1118,19 @@ static function ( $term ): array { } if ( ! empty( $include['meta'] ) && post_type_supports( $slug, 'custom-fields' ) ) { - $meta = get_post_meta( $post->ID ); - $public_meta = array(); + $meta = get_post_meta( $post->ID ); + $public_meta = array(); + $allowed_meta_keys = self::get_allowed_meta_keys( $slug ); foreach ( $meta as $key => $values ) { + // Skip protected meta keys. if ( is_protected_meta( $key, 'post' ) ) { continue; } + // Only include meta keys that are registered with show_in_abilities enabled. + if ( ! in_array( $key, $allowed_meta_keys, true ) ) { + continue; + } $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; } @@ -1218,19 +1258,38 @@ private static function process_meta_clause( array $clause ): ?array { } $allowed_compare = array( - '=', '!=', '>', '>=', '<', '<=', - 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', - 'BETWEEN', 'NOT BETWEEN', - 'EXISTS', 'NOT EXISTS', - 'REGEXP', 'NOT REGEXP', 'RLIKE', + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + 'REGEXP', + 'NOT REGEXP', + 'RLIKE', ); if ( ! empty( $clause['compare'] ) && in_array( $clause['compare'], $allowed_compare, true ) ) { $result['compare'] = $clause['compare']; } $allowed_types = array( - 'NUMERIC', 'CHAR', 'DATE', 'DATETIME', - 'TIME', 'BINARY', 'SIGNED', 'UNSIGNED', 'DECIMAL', + 'NUMERIC', + 'CHAR', + 'DATE', + 'DATETIME', + 'TIME', + 'BINARY', + 'SIGNED', + 'UNSIGNED', + 'DECIMAL', ); if ( ! empty( $clause['type'] ) && in_array( $clause['type'], $allowed_types, true ) ) { $result['type'] = $clause['type']; @@ -1251,9 +1310,16 @@ private static function process_date_clause( array $clause ): ?array { $result = array(); $int_fields = array( - 'year', 'month', 'week', 'day', - 'hour', 'minute', 'second', - 'dayofweek', 'dayofweek_iso', 'dayofyear', + 'year', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + 'dayofweek', + 'dayofweek_iso', + 'dayofyear', ); foreach ( $int_fields as $field ) { @@ -1315,4 +1381,109 @@ private static function process_date_top_level( array $input, array &$result ): $result['column'] = $input['column']; } } + + /** + * Returns all meta keys that are registered with show_in_abilities enabled for a post type. + * + * @since 7.0.0 + * + * @param string $post_type_slug The post type slug. + * @return string[] List of allowed meta keys. + */ + private static function get_allowed_meta_keys( string $post_type_slug ): array { + $registered_meta = array_merge( + get_registered_meta_keys( 'post', $post_type_slug ), + get_registered_meta_keys( 'post' ) + ); + + $allowed = array(); + foreach ( $registered_meta as $key => $args ) { + if ( ! empty( $args['show_in_abilities'] ) ) { + $allowed[] = $key; + } + } + + return $allowed; + } + + /** + * Extracts all meta keys from a meta query structure recursively. + * + * @since 7.0.0 + * + * @param array $query The meta query input. + * @return string[] List of meta keys found in the query. + */ + private static function extract_meta_keys_from_query( array $query ): array { + $keys = array(); + + if ( ! empty( $query['queries'] ) && is_array( $query['queries'] ) ) { + foreach ( $query['queries'] as $sub_query ) { + if ( ! is_array( $sub_query ) ) { + continue; + } + + if ( isset( $sub_query['queries'] ) ) { + // Nested group: recurse. + $keys = array_merge( $keys, self::extract_meta_keys_from_query( $sub_query ) ); + } elseif ( ! empty( $sub_query['key'] ) ) { + // Leaf clause with a key. + $keys[] = sanitize_key( $sub_query['key'] ); + } + } + } + + return array_unique( $keys ); + } + + /** + * Returns all public taxonomies associated with a post type. + * + * @since 7.0.0 + * + * @param string $post_type_slug The post type slug. + * @return string[] List of allowed taxonomy slugs. + */ + private static function get_allowed_taxonomies( string $post_type_slug ): array { + $taxonomies = get_object_taxonomies( $post_type_slug, 'objects' ); + $allowed = array(); + + foreach ( $taxonomies as $taxonomy ) { + if ( $taxonomy->public ) { + $allowed[] = $taxonomy->name; + } + } + + return $allowed; + } + + /** + * Extracts all taxonomy slugs from a taxonomy query structure recursively. + * + * @since 7.0.0 + * + * @param array $query The taxonomy query input. + * @return string[] List of taxonomy slugs found in the query. + */ + private static function extract_taxonomies_from_query( array $query ): array { + $taxonomies = array(); + + if ( ! empty( $query['queries'] ) && is_array( $query['queries'] ) ) { + foreach ( $query['queries'] as $sub_query ) { + if ( ! is_array( $sub_query ) ) { + continue; + } + + if ( isset( $sub_query['queries'] ) ) { + // Nested group: recurse. + $taxonomies = array_merge( $taxonomies, self::extract_taxonomies_from_query( $sub_query ) ); + } elseif ( ! empty( $sub_query['taxonomy'] ) ) { + // Leaf clause with a taxonomy. + $taxonomies[] = sanitize_key( $sub_query['taxonomy'] ); + } + } + } + + return array_unique( $taxonomies ); + } } diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index c657c6c2e7af3..452109bc9095c 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -1396,6 +1396,7 @@ function sanitize_meta( $meta_key, $meta_value, $object_type, $object_subtype = * @since 5.5.0 The `$default` argument was added to the arguments array. * @since 6.4.0 The `$revisions_enabled` argument was added to the arguments array. * @since 6.7.0 The `label` argument was added to the arguments array. + * @since 7.0.0 The `show_in_abilities` argument was added to the arguments array. * * @global array $wp_meta_keys Global registry for meta keys. * @@ -1419,13 +1420,15 @@ function sanitize_meta( $meta_key, $meta_value, $object_type, $object_subtype = * @type callable $sanitize_callback A function or method to call when sanitizing `$meta_key` data. * @type callable $auth_callback Optional. A function or method to call when performing edit_post_meta, * add_post_meta, and delete_post_meta capability checks. - * @type bool|array $show_in_rest Whether data associated with this meta key can be considered public and - * should be accessible via the REST API. A custom post type must also declare - * support for custom fields for registered meta to be accessible via REST. - * When registering complex meta values this argument may optionally be an - * array with 'schema' or 'prepare_callback' keys instead of a boolean. - * @type bool $revisions_enabled Whether to enable revisions support for this meta_key. Can only be used when the - * object type is 'post'. + * @type bool|array $show_in_rest Whether data associated with this meta key can be considered public and + * should be accessible via the REST API. A custom post type must also declare + * support for custom fields for registered meta to be accessible via REST. + * When registering complex meta values this argument may optionally be an + * array with 'schema' or 'prepare_callback' keys instead of a boolean. + * @type bool $show_in_abilities Whether this meta key should be exposed through the Abilities API. + * Default false. + * @type bool $revisions_enabled Whether to enable revisions support for this meta_key. Can only be used when the + * object type is 'post'. * } * @param string|array $deprecated Deprecated. Use `$args` instead. * @return bool True if the meta key was successfully registered in the global array, false if not. @@ -1440,16 +1443,17 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) { } $defaults = array( - 'object_subtype' => '', - 'type' => 'string', - 'label' => '', - 'description' => '', - 'default' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => null, - 'show_in_rest' => false, - 'revisions_enabled' => false, + 'object_subtype' => '', + 'type' => 'string', + 'label' => '', + 'description' => '', + 'default' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => null, + 'show_in_rest' => false, + 'show_in_abilities' => false, + 'revisions_enabled' => false, ); // There used to be individual args for sanitize and auth callbacks. diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 6ce20a1e2e2d8..ed620b767b398 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -5,6 +5,8 @@ /** * Tests for the post type get ability via the REST API. * + * @ticket 64606 + * * @covers WP_Post_Type_Abilities * * @group abilities-api @@ -143,6 +145,12 @@ public static function wpTearDownAfterClass(): void { foreach ( wp_get_ability_categories() as $category ) { wp_unregister_ability_category( $category->get_slug() ); } + + // Unregister test meta keys. + unregister_meta_key( 'post', 'footnotes' ); + unregister_meta_key( 'post', 'a' ); + unregister_meta_key( 'post', 'b' ); + unregister_meta_key( 'post', 'c' ); } /** @@ -151,6 +159,45 @@ public static function wpTearDownAfterClass(): void { public function set_up(): void { parent::set_up(); wp_set_current_user( self::$editor_id ); + + // Register meta keys with show_in_abilities enabled. + // This must be done in set_up() because the global $wp_meta_keys is reset between tests. + register_meta( + 'post', + 'footnotes', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => true, + ) + ); + register_meta( + 'post', + 'a', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => true, + ) + ); + register_meta( + 'post', + 'b', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => true, + ) + ); + register_meta( + 'post', + 'c', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => true, + ) + ); } /** @@ -184,6 +231,8 @@ static function ( $post ) { /** * Tests that the ability run route is registered. + * + * @ticket 64606 */ public function test_route_is_registered(): void { $routes = rest_get_server()->get_routes(); @@ -196,6 +245,8 @@ public function test_route_is_registered(): void { /** * Tests that POST method is rejected for this readonly ability. + * + * @ticket 64606 */ public function test_post_method_rejected(): void { $request = new WP_REST_Request( 'POST', self::ROUTE ); @@ -208,6 +259,8 @@ public function test_post_method_rejected(): void { /** * Tests retrieving a single post by ID. + * + * @ticket 64606 */ public function test_get_single_post_by_id(): void { $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); @@ -227,6 +280,8 @@ public function test_get_single_post_by_id(): void { /** * Tests retrieving a single post with meta and taxonomies included. + * + * @ticket 64606 */ public function test_get_single_post_with_meta_and_taxonomies(): void { $response = $this->dispatch_get_ability( @@ -245,10 +300,11 @@ public function test_get_single_post_with_meta_and_taxonomies(): void { // Meta should contain the public meta keys. $this->assertArrayHasKey( 'meta', $data ); - $this->assertArrayHasKey( 'footnotes', $data['meta'] ); - $this->assertArrayHasKey( 'a', $data['meta'] ); - $this->assertArrayHasKey( 'b', $data['meta'] ); - $this->assertSame( '23', $data['meta']['a'] ); + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'footnotes', $meta ); + $this->assertArrayHasKey( 'a', $meta ); + $this->assertArrayHasKey( 'b', $meta ); + $this->assertSame( '23', $meta['a'] ); // Taxonomies should contain post_tag terms. $this->assertArrayHasKey( 'taxonomies', $data ); @@ -260,6 +316,8 @@ public function test_get_single_post_with_meta_and_taxonomies(): void { /** * Tests that requesting a non-existent post returns 404. + * + * @ticket 64606 */ public function test_get_single_post_not_found(): void { $response = $this->dispatch_get_ability( array( 'id' => 999999 ) ); @@ -269,6 +327,8 @@ public function test_get_single_post_not_found(): void { /** * Tests that query mode returns paginated results. + * + * @ticket 64606 */ public function test_query_returns_paginated_results(): void { $response = $this->dispatch_get_ability( @@ -289,6 +349,8 @@ public function test_query_returns_paginated_results(): void { /** * Tests meta query with EXISTS operator finds only the post with footnotes. + * + * @ticket 64606 */ public function test_meta_query_exists(): void { $response = $this->dispatch_get_ability( @@ -320,6 +382,8 @@ public function test_meta_query_exists(): void { * Tests nested meta query: a=23 AND (b=1 OR c=1). * * Should match posts 1 and 2. + * + * @ticket 64606 */ public function test_meta_query_nested_and_or(): void { $response = $this->dispatch_get_ability( @@ -368,6 +432,8 @@ public function test_meta_query_nested_and_or(): void { * Tests nested tax query: tag t-23 AND (tag c-1 OR tag b-1). * * Should match posts 1 and 2. + * + * @ticket 64606 */ public function test_tax_query_nested_and_or(): void { $response = $this->dispatch_get_ability( @@ -417,6 +483,8 @@ public function test_tax_query_nested_and_or(): void { * * Should match posts 1, 2, 3, 5, 6 (all 2025 posts that have day=26 or month=11). * Post 4 excluded because it is from 2024. + * + * @ticket 64606 */ public function test_date_query_nested_and_or(): void { $response = $this->dispatch_get_ability( @@ -456,6 +524,8 @@ public function test_date_query_nested_and_or(): void { /** * Tests that unauthenticated requests are rejected. + * + * @ticket 64606 */ public function test_unauthenticated_query_rejected(): void { wp_set_current_user( 0 ); @@ -467,6 +537,8 @@ public function test_unauthenticated_query_rejected(): void { /** * Tests that authenticated editor can query posts. + * + * @ticket 64606 */ public function test_authenticated_query_succeeds(): void { $response = $this->dispatch_get_ability( array() ); @@ -480,6 +552,8 @@ public function test_authenticated_query_succeeds(): void { /** * Tests ordering by title ascending. + * + * @ticket 64606 */ public function test_query_with_ordering(): void { $response = $this->dispatch_get_ability( @@ -506,4 +580,149 @@ static function ( $post ) { sort( $sorted, SORT_STRING ); $this->assertSame( $sorted, $titles ); } + + /** + * Tests that only meta keys registered with show_in_abilities are included. + * + * @ticket 64606 + */ + public function test_meta_only_includes_show_in_abilities_registered_keys(): void { + // Post 4 has meta key 'x' which is NOT registered with show_in_abilities. + $response = $this->dispatch_get_ability( + array( + 'id' => self::$post_ids[4], + 'include' => array( 'meta' => true ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'meta', $data ); + + // The 'x' meta key should NOT be present since it's not registered with show_in_abilities. + $this->assertArrayNotHasKey( 'x', (array) $data['meta'] ); + } + + /** + * Tests that meta query with unregistered meta key returns an error. + * + * @ticket 64606 + */ + public function test_meta_query_with_invalid_key_returns_error(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'invalid_meta_key', + 'compare' => 'EXISTS', + ), + ), + ), + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'invalid_meta_key', $data['code'] ); + } + + /** + * Tests that meta query with valid registered meta key succeeds. + * + * @ticket 64606 + */ + public function test_meta_query_with_valid_key_succeeds(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'a', + 'compare' => '=', + 'value' => '23', + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'posts', $data ); + } + + /** + * Tests that tax query with non-public taxonomy returns an error. + * + * @ticket 64606 + */ + public function test_tax_query_with_non_public_taxonomy_returns_error(): void { + // Register a non-public taxonomy for testing. + register_taxonomy( + 'private_tax', + 'post', + array( + 'public' => false, + ) + ); + + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'tax' => array( + 'queries' => array( + array( + 'taxonomy' => 'private_tax', + 'terms' => array( 'test-term' ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'invalid_taxonomy', $data['code'] ); + + // Clean up. + unregister_taxonomy( 'private_tax' ); + } + + /** + * Tests that tax query with public taxonomy succeeds. + * + * @ticket 64606 + */ + public function test_tax_query_with_public_taxonomy_succeeds(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'tax' => array( + 'queries' => array( + array( + 'taxonomy' => 'post_tag', + 'terms' => array( 't-23' ), + 'field' => 'slug', + ), + ), + ), + ), + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'posts', $data ); + } } From f9ae672716e293619a546799376b328110184961 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 13 Feb 2026 13:20:23 +0000 Subject: [PATCH 08/34] include possible meta keys and taxonomies on the schema --- .../class-wp-post-type-abilities.php | 40 ++++++++++----- .../abilities-api/wpPostTypeAbilitiesRest.php | 51 ++++++++++++++++++- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 2cd15035f89b9..c1b3538d0a844 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -143,11 +143,11 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) $query_properties = array( 'tax' => self::build_query_group_schema( __( 'Taxonomy query to filter posts by taxonomy terms.' ), - self::build_tax_clause_schema() + self::build_tax_clause_schema( $slug ) ), 'meta' => self::build_query_group_schema( __( 'Meta query to filter posts by post meta values.' ), - self::build_meta_clause_schema() + self::build_meta_clause_schema( $slug ) ), 'date' => self::build_date_query_schema(), ); @@ -310,17 +310,25 @@ private static function build_query_group_schema( string $description, array $le * * @since 7.0.0 * + * @param string $post_type_slug The post type slug. * @return array The JSON schema for a taxonomy query clause. */ - private static function build_tax_clause_schema(): array { + private static function build_tax_clause_schema( string $post_type_slug ): array { + $allowed_taxonomies = self::get_allowed_taxonomies( $post_type_slug ); + + $taxonomy_schema = array( + 'type' => 'string', + 'description' => __( 'Taxonomy slug to query.' ), + ); + if ( ! empty( $allowed_taxonomies ) ) { + $taxonomy_schema['enum'] = $allowed_taxonomies; + } + return array( 'type' => 'object', 'required' => array( 'taxonomy', 'terms' ), 'properties' => array( - 'taxonomy' => array( - 'type' => 'string', - 'description' => __( 'Taxonomy slug to query.' ), - ), + 'taxonomy' => $taxonomy_schema, 'terms' => array( 'type' => 'array', 'description' => __( 'Taxonomy terms to match.' ), @@ -352,17 +360,25 @@ private static function build_tax_clause_schema(): array { * * @since 7.0.0 * + * @param string $post_type_slug The post type slug. * @return array The JSON schema for a meta query clause. */ - private static function build_meta_clause_schema(): array { + private static function build_meta_clause_schema( string $post_type_slug ): array { + $allowed_meta_keys = self::get_allowed_meta_keys( $post_type_slug ); + + $key_schema = array( + 'type' => 'string', + 'description' => __( 'Meta key to query.' ), + ); + if ( ! empty( $allowed_meta_keys ) ) { + $key_schema['enum'] = $allowed_meta_keys; + } + return array( 'type' => 'object', 'required' => array( 'key' ), 'properties' => array( - 'key' => array( - 'type' => 'string', - 'description' => __( 'Meta key to query.' ), - ), + 'key' => $key_schema, 'value' => array( 'type' => array( 'string', 'integer', 'array' ), 'description' => __( 'Meta value to match. Use an array for BETWEEN, NOT BETWEEN, IN, and NOT IN comparisons.' ), diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index ed620b767b398..8018ff4cf11d0 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -45,6 +45,14 @@ class Tests_Abilities_API_WpPostTypeAbilitiesRest extends WP_Test_REST_TestCase * @param WP_UnitTest_Factory $factory Test factory. */ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void { + // Unregister any existing abilities and categories to start fresh. + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $category ) { + wp_unregister_ability_category( $category->get_slug() ); + } + // Ensure core abilities are registered. remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); @@ -198,6 +206,43 @@ public function set_up(): void { 'show_in_abilities' => true, ) ); + + // Unregister all existing abilities so we can re-register with updated schema. + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + + // Remove core abilities registration to prevent ALL abilities from being registered + // when we only want to re-register post type abilities with updated schema. + remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + + // Simulate the init action to allow re-registration without "doing it wrong" warning. + $this->simulate_doing_wp_abilities_init_action(); + + // Re-register all post type abilities so the schema includes the meta keys enum. + WP_Post_Type_Abilities::register(); + + // Clean up the simulated action. + $this->end_simulated_wp_abilities_init_action(); + } + + /** + * Simulates the `wp_abilities_api_init` action. + * + * This makes `doing_action('wp_abilities_api_init')` return true without + * firing all hooks registered on that action. + */ + private function simulate_doing_wp_abilities_init_action(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; + } + + /** + * Ends the simulated `wp_abilities_api_init` action. + */ + private function end_simulated_wp_abilities_init_action(): void { + global $wp_current_filter; + array_pop( $wp_current_filter ); } /** @@ -628,7 +673,8 @@ public function test_meta_query_with_invalid_key_returns_error(): void { $this->assertSame( 400, $response->get_status() ); $data = $response->get_data(); - $this->assertSame( 'invalid_meta_key', $data['code'] ); + // Schema validation catches invalid meta keys. + $this->assertSame( 'ability_invalid_input', $data['code'] ); } /** @@ -692,7 +738,8 @@ public function test_tax_query_with_non_public_taxonomy_returns_error(): void { $this->assertSame( 400, $response->get_status() ); $data = $response->get_data(); - $this->assertSame( 'invalid_taxonomy', $data['code'] ); + // Schema validation catches non-public taxonomies. + $this->assertSame( 'ability_invalid_input', $data['code'] ); // Clean up. unregister_taxonomy( 'private_tax' ); From fee6c0aab877aee25e545b4123fe5c4245c7996f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 14:18:47 +0000 Subject: [PATCH 09/34] included missing show_in_abilities on tests --- tests/phpunit/tests/meta/registerMeta.php | 5 +++++ .../tests/user/wpRegisterPersistedPreferencesMeta.php | 1 + 2 files changed, 6 insertions(+) diff --git a/tests/phpunit/tests/meta/registerMeta.php b/tests/phpunit/tests/meta/registerMeta.php index 30b6920bdea0d..8ce13e5ead420 100644 --- a/tests/phpunit/tests/meta/registerMeta.php +++ b/tests/phpunit/tests/meta/registerMeta.php @@ -98,6 +98,7 @@ public function test_register_meta_with_post_object_type_populates_wp_meta_keys( 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, + 'show_in_abilities' => false, 'revisions_enabled' => false, ), ), @@ -124,6 +125,7 @@ public function test_register_meta_with_term_object_type_populates_wp_meta_keys( 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, + 'show_in_abilities' => false, 'revisions_enabled' => false, ), ), @@ -180,6 +182,7 @@ public function test_register_meta_with_current_sanitize_callback_populates_wp_m 'sanitize_callback' => array( $this, '_new_sanitize_meta_cb' ), 'auth_callback' => '__return_true', 'show_in_rest' => false, + 'show_in_abilities' => false, 'revisions_enabled' => false, ), ), @@ -362,6 +365,7 @@ public function test_register_meta_with_subtype_populates_wp_meta_keys( $type, $ 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, + 'show_in_abilities' => false, 'revisions_enabled' => false, ), ), @@ -417,6 +421,7 @@ public function test_unregister_meta_without_subtype_keeps_subtype_meta_key( $ty 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, + 'show_in_abilities' => false, 'revisions_enabled' => false, ), ), diff --git a/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php b/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php index a45b015ad9728..5d8d2dee08143 100644 --- a/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php +++ b/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php @@ -53,6 +53,7 @@ public function test_should_register_persisted_preferences_meta() { 'additionalProperties' => true, ), ), + 'show_in_abilities' => false, 'revisions_enabled' => false, ), $wp_meta_keys['user'][''][ $meta_key ], From 9033403730b203aca8de9dffbfbad175139c81a2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 15:25:47 +0000 Subject: [PATCH 10/34] make posty type speicific meta overwrite global post --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index c1b3538d0a844..118fad6d771ba 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1408,8 +1408,8 @@ private static function process_date_top_level( array $input, array &$result ): */ private static function get_allowed_meta_keys( string $post_type_slug ): array { $registered_meta = array_merge( + get_registered_meta_keys( 'post' ), get_registered_meta_keys( 'post', $post_type_slug ), - get_registered_meta_keys( 'post' ) ); $allowed = array(); From d48bdcd236313c3c6566597cb2c9b13c9974196e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:15:44 +0000 Subject: [PATCH 11/34] make status an array and make permissions match that --- .../class-wp-post-type-abilities.php | 82 ++++++++++++++++--- .../abilities-api/wpPostTypeAbilitiesRest.php | 60 +++++++++++++- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 118fad6d771ba..2174c79e1928f 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -170,9 +170,14 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) 'minimum' => 1, ), 'status' => array( - 'type' => 'string', - 'description' => __( 'Filter by post status.' ), - 'enum' => $statuses, + 'type' => 'array', + 'description' => __( 'Filter by one or more post statuses. Defaults to publish.' ), + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'default' => array( 'publish' ), ), 'search' => array( 'type' => 'string', @@ -843,24 +848,43 @@ private static function make_execute_get_callback( WP_Post_Type $post_type_objec * @return Closure The permission callback. */ private static function make_permission_get_callback( WP_Post_Type $post_type_object ): Closure { - return static function ( $input = array() ) use ( $post_type_object ): bool { + return static function ( $input = array() ) use ( $post_type_object ) { $input = is_array( $input ) ? $input : array(); - // For single post retrieval, check specific post permission. - // If the post doesn't exist, verify the user has general read - // capability before letting the execute callback return a 404. if ( ! empty( $input['id'] ) ) { $post = get_post( (int) $input['id'] ); if ( ! $post || $post->post_type !== $post_type_object->name ) { - return current_user_can( $post_type_object->cap->read_others_posts ?? 'read' ); + return current_user_can( $post_type_object->cap->read ?? 'read' ); } - return current_user_can( 'read_post', $post->ID ); + return self::check_post_read_permission( $post ); } - // For queries, check general read capability. - return current_user_can( $post_type_object->cap->read ?? 'read' ); + $statuses = self::normalize_statuses_input( $input ); + + if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { + return false; + } + + if ( count( $statuses ) === 1 && $statuses[0] === 'publish' ) { + return current_user_can( $post_type_object->cap->read ?? 'read' ); + } + + if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { + return true; + } + + if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { + foreach ( $statuses as $status ) { + if ( 'private' !== $status && 'publish' !== $status ) { + return false; + } + } + return true; + } + + return false; }; } @@ -903,10 +927,9 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array $query_args = array( 'post_type' => $post_type_object->name, - 'post_status' => $input['status'] ?? 'publish', + 'post_status' => self::normalize_statuses_input( $input ), 'posts_per_page' => $per_page, 'paged' => $page, - 'perm' => 'readable', ); if ( ! empty( $input['search'] ) ) { @@ -1024,6 +1047,10 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array $posts = array(); foreach ( $query->posts as $post ) { + if ( ! self::check_post_read_permission( $post ) ) { + continue; + } + $posts[] = self::format_post( $post, $post_type_object, $input ); } @@ -1034,6 +1061,35 @@ private static function execute_get_query( WP_Post_Type $post_type_object, array ); } + /** + * Checks whether a post is readable, matching WP_REST_Posts_Controller behavior. + * + * @since 7.0.0 + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be read. + */ + private static function check_post_read_permission( WP_Post $post ): bool { + return current_user_can( 'read_post', $post->ID ); + } + + /** + * Normalizes input statuses to a non-empty array with publish as the default. + * + * @since 7.0.0 + * + * @param array $input Input parameters. + * @return string[] Normalized list of statuses. + */ + private static function normalize_statuses_input( array $input ): array { + $statuses = $input['status'] ?? array( 'publish' ); + if ( ! is_array( $statuses ) || empty( $statuses ) ) { + return array( 'publish' ); + } + + return $statuses; + } + /** * Formats a post object into the ability output format. * diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 8018ff4cf11d0..73f96b40d306e 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -26,7 +26,7 @@ class Tests_Abilities_API_WpPostTypeAbilitiesRest extends WP_Test_REST_TestCase protected static $editor_id; /** - * Test post IDs indexed 1-6 matching the fixture table. + * Test post IDs indexed 1-7 matching the fixture table. * * @var int[] */ @@ -138,6 +138,16 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void 'post_date' => '2025-06-26 10:00:00', ) ); + + // Post 7: private post, used for permission checks. + self::$post_ids[7] = $factory->post->create( + array( + 'post_title' => 'Private post', + 'post_status' => 'private', + 'post_author' => self::$editor_id, + 'post_date' => '2025-12-01 10:00:00', + ) + ); } /** @@ -568,11 +578,11 @@ public function test_date_query_nested_and_or(): void { } /** - * Tests that unauthenticated requests are rejected. + * Tests that unauthenticated requests cannot query published posts. * * @ticket 64606 */ - public function test_unauthenticated_query_rejected(): void { + public function test_unauthenticated_query_published_posts_rejected(): void { wp_set_current_user( 0 ); $response = $this->dispatch_get_ability( array() ); @@ -580,6 +590,50 @@ public function test_unauthenticated_query_rejected(): void { $this->assertContains( $response->get_status(), array( 401, 403 ) ); } + /** + * Tests that unauthenticated requests cannot query private posts. + * + * @ticket 64606 + */ + public function test_unauthenticated_query_private_status_rejected(): void { + wp_set_current_user( 0 ); + + $response = $this->dispatch_get_ability( + array( + 'status' => array( 'private' ), + ) + ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + $this->assertSame( 'rest_ability_cannot_execute', $response->get_data()['code'] ); + } + + /** + * Tests that unauthenticated requests cannot read a published post by ID. + * + * @ticket 64606 + */ + public function test_unauthenticated_get_single_published_post_rejected(): void { + wp_set_current_user( 0 ); + + $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + } + + /** + * Tests that unauthenticated requests cannot read a private post by ID. + * + * @ticket 64606 + */ + public function test_unauthenticated_get_single_private_post_rejected(): void { + wp_set_current_user( 0 ); + + $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[7] ) ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + } + /** * Tests that authenticated editor can query posts. * From 14b5372b8121764415ef8f089fd919053700d36f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:27:13 +0000 Subject: [PATCH 12/34] fix an issue where meta was case insisitive, add stricted schame validation for multiple level nesting --- .../class-wp-post-type-abilities.php | 91 ++++++---- .../abilities-api/wpPostTypeAbilitiesRest.php | 163 ++++++++++++++++-- 2 files changed, 202 insertions(+), 52 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 2174c79e1928f..cf6704aa9c5c7 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -268,46 +268,79 @@ private static function build_get_input_schema( WP_Post_Type $post_type_object ) * @return array The JSON schema for the query group. */ private static function build_query_group_schema( string $description, array $leaf_schema ): array { - $nested_group_schema = array( + $nested_group_schema = self::build_nested_group_schema( + $leaf_schema, + __( 'Nested query group with its own relation.' ), + __( 'Nested query clauses.' ) + ); + + return array( 'type' => 'object', - 'description' => __( 'Nested query group with its own relation.' ), - 'required' => array( 'queries' ), + 'description' => $description, 'properties' => array( 'relation' => array( 'type' => 'string', - 'description' => __( 'Logical relation between nested clauses.' ), + 'description' => __( 'Logical relation between query clauses.' ), 'enum' => array( 'AND', 'OR' ), ), 'queries' => array( 'type' => 'array', - 'description' => __( 'Nested query clauses.' ), + 'description' => __( 'List of query clauses or nested groups.' ), + 'items' => array( + 'oneOf' => array( + $leaf_schema, + $nested_group_schema, + ), + ), ), ), 'additionalProperties' => false, ); + } - return array( + /** + * Builds a nested query group schema recursively. + * + * @since 7.0.0 + * + * @param array $leaf_schema JSON Schema for a leaf clause. + * @param string $group_description Description for the nested group. + * @param string $queries_description Description for the nested queries array. + * @param int $depth Remaining recursion depth. + * @return array The nested query group schema. + */ + private static function build_nested_group_schema( array $leaf_schema, string $group_description, string $queries_description, int $depth = 3 ): array { + $group_schema = array( 'type' => 'object', - 'description' => $description, + 'description' => $group_description, + 'required' => array( 'queries' ), 'properties' => array( 'relation' => array( 'type' => 'string', - 'description' => __( 'Logical relation between query clauses.' ), + 'description' => __( 'Logical relation between nested clauses.' ), 'enum' => array( 'AND', 'OR' ), ), 'queries' => array( 'type' => 'array', - 'description' => __( 'List of query clauses or nested groups.' ), - 'items' => array( - 'oneOf' => array( - $leaf_schema, - $nested_group_schema, - ), - ), + 'description' => $queries_description, ), ), 'additionalProperties' => false, ); + + if ( $depth <= 0 ) { + $group_schema['properties']['queries']['items'] = $leaf_schema; + return $group_schema; + } + + $group_schema['properties']['queries']['items'] = array( + 'oneOf' => array( + $leaf_schema, + self::build_nested_group_schema( $leaf_schema, $group_description, $queries_description, $depth - 1 ), + ), + ); + + return $group_schema; } /** @@ -541,22 +574,10 @@ private static function build_date_query_schema(): array { 'additionalProperties' => false, ); - $nested_group_schema = array( - 'type' => 'object', - 'description' => __( 'Nested date query group with its own relation.' ), - 'required' => array( 'queries' ), - 'properties' => array( - 'relation' => array( - 'type' => 'string', - 'description' => __( 'Logical relation between nested clauses.' ), - 'enum' => array( 'AND', 'OR' ), - ), - 'queries' => array( - 'type' => 'array', - 'description' => __( 'Nested date query clauses.' ), - ), - ), - 'additionalProperties' => false, + $nested_group_schema = self::build_nested_group_schema( + $date_clause_schema, + __( 'Nested date query group with its own relation.' ), + __( 'Nested date query clauses.' ) ); return array( @@ -1317,12 +1338,12 @@ private static function process_tax_clause( array $clause ): ?array { * @return array|null The processed clause or null if invalid. */ private static function process_meta_clause( array $clause ): ?array { - if ( empty( $clause['key'] ) ) { + if ( ! isset( $clause['key'] ) || ! is_string( $clause['key'] ) || '' === $clause['key'] ) { return null; } $result = array( - 'key' => sanitize_key( $clause['key'] ), + 'key' => $clause['key'], ); if ( isset( $clause['value'] ) ) { @@ -1498,9 +1519,9 @@ private static function extract_meta_keys_from_query( array $query ): array { if ( isset( $sub_query['queries'] ) ) { // Nested group: recurse. $keys = array_merge( $keys, self::extract_meta_keys_from_query( $sub_query ) ); - } elseif ( ! empty( $sub_query['key'] ) ) { + } elseif ( isset( $sub_query['key'] ) && is_string( $sub_query['key'] ) && '' !== $sub_query['key'] ) { // Leaf clause with a key. - $keys[] = sanitize_key( $sub_query['key'] ); + $keys[] = $sub_query['key']; } } } diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 73f96b40d306e..c9bc510034a1a 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -85,6 +85,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void update_post_meta( self::$post_ids[1], 'footnotes', '[{}]' ); update_post_meta( self::$post_ids[1], 'a', '23' ); update_post_meta( self::$post_ids[1], 'b', '1' ); + update_post_meta( self::$post_ids[1], 'My.Key', 'special' ); wp_set_object_terms( self::$post_ids[1], array( 't-23', 'c-1' ), 'post_tag' ); // Post 2: a=23, c=1, tags t-23 & b-1, date 2025-11-15. @@ -169,6 +170,7 @@ public static function wpTearDownAfterClass(): void { unregister_meta_key( 'post', 'a' ); unregister_meta_key( 'post', 'b' ); unregister_meta_key( 'post', 'c' ); + unregister_meta_key( 'post', 'My.Key' ); } /** @@ -216,24 +218,17 @@ public function set_up(): void { 'show_in_abilities' => true, ) ); + register_meta( + 'post', + 'My.Key', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => true, + ) + ); - // Unregister all existing abilities so we can re-register with updated schema. - foreach ( wp_get_abilities() as $ability ) { - wp_unregister_ability( $ability->get_name() ); - } - - // Remove core abilities registration to prevent ALL abilities from being registered - // when we only want to re-register post type abilities with updated schema. - remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); - - // Simulate the init action to allow re-registration without "doing it wrong" warning. - $this->simulate_doing_wp_abilities_init_action(); - - // Re-register all post type abilities so the schema includes the meta keys enum. - WP_Post_Type_Abilities::register(); - - // Clean up the simulated action. - $this->end_simulated_wp_abilities_init_action(); + $this->reregister_post_type_abilities(); } /** @@ -255,6 +250,20 @@ private function end_simulated_wp_abilities_init_action(): void { array_pop( $wp_current_filter ); } + /** + * Re-registers post type abilities so input schemas include the current meta key registry. + */ + private function reregister_post_type_abilities(): void { + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + + remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + $this->simulate_doing_wp_abilities_init_action(); + WP_Post_Type_Abilities::register(); + $this->end_simulated_wp_abilities_init_action(); + } + /** * Dispatches a GET request to the post type get ability endpoint. * @@ -759,6 +768,126 @@ public function test_meta_query_with_valid_key_succeeds(): void { $this->assertArrayHasKey( 'posts', $data ); } + /** + * Tests that meta query works with a registered non-slug meta key. + * + * @ticket 64606 + */ + public function test_meta_query_with_non_slug_registered_key_succeeds(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'My.Key', + 'compare' => 'EXISTS', + ), + ), + ), + ), + 'per_page' => 100, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 1, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + } + + /** + * Tests that post-type-specific show_in_abilities overrides global registration. + * + * @ticket 64606 + */ + public function test_meta_query_respects_post_type_specific_show_in_abilities_override(): void { + register_meta( + 'post', + 'override_key', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => false, + ) + ); + + register_meta( + 'post', + 'override_key', + array( + 'object_subtype' => 'post', + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => true, + ) + ); + + update_post_meta( self::$post_ids[1], 'override_key', '1' ); + $this->reregister_post_type_abilities(); + + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'override_key', + 'compare' => 'EXISTS', + ), + ), + ), + ), + 'per_page' => 100, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertSame( 1, $data['total'] ); + $this->assertContains( self::$post_ids[1], $post_ids ); + } + + /** + * Tests that invalid clauses in deeply nested meta queries fail schema validation. + * + * @ticket 64606 + */ + public function test_meta_query_deep_nested_invalid_clause_rejected(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'relation' => 'AND', + 'queries' => array( + array( + 'relation' => 'OR', + 'queries' => array( + array( + 'foo' => 'bar', + ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + } + /** * Tests that tax query with non-public taxonomy returns an error. * From bd076174bdb467d7ca09f16b099c3b60c12896a4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:27:32 +0000 Subject: [PATCH 13/34] lint fixes --- src/wp-includes/meta.php | 22 +++++++++++----------- src/wp-includes/option.php | 1 + 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index 452109bc9095c..ce90084e72652 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -1443,17 +1443,17 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) { } $defaults = array( - 'object_subtype' => '', - 'type' => 'string', - 'label' => '', - 'description' => '', - 'default' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => null, - 'show_in_rest' => false, - 'show_in_abilities' => false, - 'revisions_enabled' => false, + 'object_subtype' => '', + 'type' => 'string', + 'label' => '', + 'description' => '', + 'default' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => null, + 'show_in_rest' => false, + 'show_in_abilities' => false, + 'revisions_enabled' => false, ); // There used to be individual args for sanitize and auth callbacks. diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 970ac39652195..733fc93f09fff 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3019,6 +3019,7 @@ function register_setting( $option_group, $option_name, $args = array() ) { 'description' => '', 'sanitize_callback' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, ); // Back-compat: old sanitize callback is added. From 962be8c9c5e45447ee36ec6927f8edfbd7b0cf39 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:31:46 +0000 Subject: [PATCH 14/34] yoda condition --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index cf6704aa9c5c7..8c4c5121bf870 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -888,7 +888,7 @@ private static function make_permission_get_callback( WP_Post_Type $post_type_ob return false; } - if ( count( $statuses ) === 1 && $statuses[0] === 'publish' ) { + if ( 1 === count( $statuses ) && 'publish' === $statuses[0] ) { return current_user_can( $post_type_object->cap->read ?? 'read' ); } From c388417e370a34ee24288530b0f3f5dcabaa3225 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:55:03 +0000 Subject: [PATCH 15/34] remove redundat permission check --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 8c4c5121bf870..097432927a89f 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -889,7 +889,7 @@ private static function make_permission_get_callback( WP_Post_Type $post_type_ob } if ( 1 === count( $statuses ) && 'publish' === $statuses[0] ) { - return current_user_can( $post_type_object->cap->read ?? 'read' ); + return true; } if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { From a8879e0e0fcf8b381b54e51953cfaae02a51c2b7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:55:24 +0000 Subject: [PATCH 16/34] fix schema to support non string meta types --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 097432927a89f..aa4ae17a19368 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -819,7 +819,7 @@ private static function build_post_schema( WP_Post_Type $post_type_object ): arr 'type' => 'object', 'description' => __( 'Public post meta key-value pairs. Only present when include.meta is true.' ), 'additionalProperties' => array( - 'type' => array( 'string', 'array' ), + 'type' => array( 'array', 'object', 'string', 'number', 'integer', 'boolean', 'null' ), ), ); } From 3b0446ab2506998d36ddd7d923500c6ff8cae84d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:55:47 +0000 Subject: [PATCH 17/34] test object meta value --- .../abilities-api/wpPostTypeAbilitiesRest.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index c9bc510034a1a..4cf7e39ae1acb 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -171,6 +171,7 @@ public static function wpTearDownAfterClass(): void { unregister_meta_key( 'post', 'b' ); unregister_meta_key( 'post', 'c' ); unregister_meta_key( 'post', 'My.Key' ); + unregister_meta_key( 'post', 'structured_object' ); } /** @@ -227,6 +228,15 @@ public function set_up(): void { 'show_in_abilities' => true, ) ); + register_meta( + 'post', + 'structured_object', + array( + 'type' => 'object', + 'single' => true, + 'show_in_abilities' => true, + ) + ); $this->reregister_post_type_abilities(); } @@ -712,6 +722,52 @@ public function test_meta_only_includes_show_in_abilities_registered_keys(): voi $this->assertArrayNotHasKey( 'x', (array) $data['meta'] ); } + /** + * Tests that object-like meta values are allowed by the output schema. + * + * @ticket 64606 + */ + public function test_meta_include_supports_object_values(): void { + $filter = static function ( $check, $object_id, $meta_key, $single, $meta_type ) { + if ( 'post' !== $meta_type || self::$post_ids[1] !== $object_id || '' !== $meta_key || $single ) { + return $check; + } + + return array( + 'structured_object' => array( + (object) array( + 'foo' => 'bar', + 'nested' => array( + 'baz' => 'qux', + ), + ), + ), + ); + }; + + add_filter( 'get_post_metadata', $filter, 10, 5 ); + + $response = $this->dispatch_get_ability( + array( + 'id' => self::$post_ids[1], + 'include' => array( 'meta' => true ), + ) + ); + remove_filter( 'get_post_metadata', $filter, 10 ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'meta', $data ); + + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'structured_object', $meta ); + + $structured_object = (array) $meta['structured_object']; + $this->assertSame( 'bar', $structured_object['foo'] ); + $this->assertSame( 'qux', $structured_object['nested']['baz'] ); + } + /** * Tests that meta query with unregistered meta key returns an error. * From c1f173e0dbafc8612ada1afd7c246e2dd10a51ff Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:58:04 +0000 Subject: [PATCH 18/34] add missing doc --- src/wp-includes/post.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index c7a1d71b51f99..f7eb0b48e3da5 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1710,6 +1710,7 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @since 5.0.0 The `template` and `template_lock` arguments were added. * @since 5.3.0 The `supports` argument will now accept an array of arguments for a feature. * @since 5.9.0 The `rest_namespace` argument was added. + * @since 7.0.0 The `show_in_abilities` argument was added. * * @global array $wp_post_types List of post types. * @@ -1755,6 +1756,10 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * of $show_in_menu. * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true * for the post type to be available in the block editor. + * @type bool|array $show_in_abilities Whether to register abilities for this post type via the Abilities API. + * Accepts a boolean or an array of ability names mapped to booleans. + * If true, all supported abilities are registered. + * If false, no abilities are registered. Default false. * @type string $rest_base To change the base URL of REST API route. Default is $post_type. * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. From 3a945a1068e3cfd4e5dd78f322f741cb3826cda0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 16:59:39 +0000 Subject: [PATCH 19/34] add status sanitization --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index aa4ae17a19368..4fc1e2a29e145 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1108,7 +1108,7 @@ private static function normalize_statuses_input( array $input ): array { return array( 'publish' ); } - return $statuses; + return array_map( 'sanitize_key', $statuses ); } /** From cfd47983f9de1d3a8d3ff1457707d5ee465c5ee4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:01:21 +0000 Subject: [PATCH 20/34] add comment related to json encoding --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 4fc1e2a29e145..83148b40fb37b 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1207,6 +1207,7 @@ static function ( $term ): array { } } + // Use stdClass for empty value to ensure JSON encodes as {} not []. $data['taxonomies'] = ! empty( $terms_data ) ? $terms_data : new stdClass(); } @@ -1227,6 +1228,7 @@ static function ( $term ): array { $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; } + // Use stdClass for empty value to ensure JSON encodes as {} not []. $data['meta'] = ! empty( $public_meta ) ? $public_meta : new stdClass(); } From 62fbff7711f3a7943a219eb3cf52146b5848e8a0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:01:37 +0000 Subject: [PATCH 21/34] make meta keys unique --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 83148b40fb37b..111f828ce1325 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1498,7 +1498,7 @@ private static function get_allowed_meta_keys( string $post_type_slug ): array { } } - return $allowed; + return array_unique( $allowed ); } /** From 372048782c39fd0878783d89dbc668a9b0a7fb65 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:10:21 +0000 Subject: [PATCH 22/34] don't use strtolower on labels --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 111f828ce1325..8bdc3ea612a34 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -81,8 +81,8 @@ private static function register_get_ability( WP_Post_Type $post_type_object ): 'description' => sprintf( /* translators: %1$s: Post type singular name (lowercase), %2$s: Post type plural name (lowercase). */ __( 'Retrieves a single %1$s by ID or queries multiple %2$s with optional filters.' ), - strtolower( $label ), - strtolower( $post_type_object->labels->name ?? $post_type_object->label ) + $label, + $post_type_object->labels->name ?? $post_type_object->label ), 'category' => 'post', 'input_schema' => self::build_get_input_schema( $post_type_object ), From 0f8738c4d28a7ab0a3ca924c48f22d67f453fcef Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:10:32 +0000 Subject: [PATCH 23/34] improve error messagem --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 8bdc3ea612a34..3fbde9f80c9c8 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -925,7 +925,7 @@ private static function execute_get_single( int $post_id, WP_Post_Type $post_typ if ( ! $post || $post->post_type !== $post_type_object->name ) { return new WP_Error( 'post_not_found', - __( 'Post not found.' ), + __( 'The requested post was not found or is not of this type.' ), array( 'status' => 404 ) ); } From 94792d7083276f6fff3b146009a2e35aa009ce0d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:10:42 +0000 Subject: [PATCH 24/34] document depth --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 3fbde9f80c9c8..b193d84f79ecc 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -301,12 +301,16 @@ private static function build_query_group_schema( string $description, array $le /** * Builds a nested query group schema recursively. * + * The depth parameter limits how deeply nested query groups can be. With the default + * depth of 3, consumers can nest up to 3 levels of AND/OR groups. This prevents + * infinitely recursive schemas while allowing sufficiently complex queries. + * * @since 7.0.0 * * @param array $leaf_schema JSON Schema for a leaf clause. * @param string $group_description Description for the nested group. * @param string $queries_description Description for the nested queries array. - * @param int $depth Remaining recursion depth. + * @param int $depth Remaining recursion depth. Default 3. * @return array The nested query group schema. */ private static function build_nested_group_schema( array $leaf_schema, string $group_description, string $queries_description, int $depth = 3 ): array { From e279d0d417491c5bfb7d716ad676e8cb6172b6fd Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:13:35 +0000 Subject: [PATCH 25/34] make queries required inside query properties --- .../class-wp-post-type-abilities.php | 1 + .../abilities-api/wpPostTypeAbilitiesRest.php | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index b193d84f79ecc..a468d19b4e110 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -277,6 +277,7 @@ private static function build_query_group_schema( string $description, array $le return array( 'type' => 'object', 'description' => $description, + 'required' => array( 'queries' ), 'properties' => array( 'relation' => array( 'type' => 'string', diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 4cf7e39ae1acb..85c2e4115a0cd 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -944,6 +944,26 @@ public function test_meta_query_deep_nested_invalid_clause_rejected(): void { $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); } + /** + * Tests that top-level meta query requires a queries list. + * + * @ticket 64606 + */ + public function test_meta_query_missing_queries_rejected(): void { + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'relation' => 'AND', + ), + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + } + /** * Tests that tax query with non-public taxonomy returns an error. * From 784b97efda95c36682db37232987e63de515cce5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:13:47 +0000 Subject: [PATCH 26/34] improve format setting code --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index a468d19b4e110..3828908e8e030 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1174,8 +1174,7 @@ private static function format_post( WP_Post $post, WP_Post_Type $post_type_obje } if ( post_type_supports( $slug, 'post-formats' ) ) { - $format = get_post_format( $post ); - $data['format'] = $format ? $format : 'standard'; + $data['format'] = get_post_format( $post ) ?: 'standard'; } if ( post_type_supports( $slug, 'comments' ) ) { From d05696e417c4ba9ac53803ec41f7ca522f3c8949 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:18:40 +0000 Subject: [PATCH 27/34] respect is single on meta --- .../abilities/class-wp-post-type-abilities.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 3828908e8e030..d0e1a38124979 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1219,17 +1219,23 @@ static function ( $term ): array { $meta = get_post_meta( $post->ID ); $public_meta = array(); $allowed_meta_keys = self::get_allowed_meta_keys( $slug ); + $registered_meta = array_merge( + get_registered_meta_keys( 'post' ), + get_registered_meta_keys( 'post', $slug ) + ); foreach ( $meta as $key => $values ) { // Skip protected meta keys. if ( is_protected_meta( $key, 'post' ) ) { continue; } - // Only include meta keys that are registered with show_in_abilities enabled. + // Only include meta keys that are explicitly allowed. if ( ! in_array( $key, $allowed_meta_keys, true ) ) { continue; } - $public_meta[ $key ] = count( $values ) === 1 ? $values[0] : $values; + // Respect the registered 'single' property for consistent behavior with get_post_meta(). + $is_single = ! empty( $registered_meta[ $key ]['single'] ); + $public_meta[ $key ] = $is_single ? ( $values[0] ?? null ) : $values; } // Use stdClass for empty value to ensure JSON encodes as {} not []. From 3d75d34877aef68937e9537f7f9a55b3fcdd55bc Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:24:16 +0000 Subject: [PATCH 28/34] lint fixes --- tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 85c2e4115a0cd..3ff2337273ad2 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -832,7 +832,7 @@ public function test_meta_query_with_valid_key_succeeds(): void { public function test_meta_query_with_non_slug_registered_key_succeeds(): void { $response = $this->dispatch_get_ability( array( - 'query' => array( + 'query' => array( 'meta' => array( 'queries' => array( array( @@ -887,7 +887,7 @@ public function test_meta_query_respects_post_type_specific_show_in_abilities_ov $response = $this->dispatch_get_ability( array( - 'query' => array( + 'query' => array( 'meta' => array( 'queries' => array( array( From 124597dd26b65d310d9b1fe5c659a5274ea50544 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:32:09 +0000 Subject: [PATCH 29/34] revert format change because of phpcs --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index d0e1a38124979..4a1b12c2db6a7 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1174,7 +1174,8 @@ private static function format_post( WP_Post $post, WP_Post_Type $post_type_obje } if ( post_type_supports( $slug, 'post-formats' ) ) { - $data['format'] = get_post_format( $post ) ?: 'standard'; + $format = get_post_format( $post ); + $data['format'] = $format ? $format : 'standard'; } if ( post_type_supports( $slug, 'comments' ) ) { From 818fd0fb68867e76b87101cd436adc726b8c33da Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:33:46 +0000 Subject: [PATCH 30/34] fix comment --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 4a1b12c2db6a7..2dec8a64a6a9c 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -79,7 +79,7 @@ private static function register_get_ability( WP_Post_Type $post_type_object ): $label ), 'description' => sprintf( - /* translators: %1$s: Post type singular name (lowercase), %2$s: Post type plural name (lowercase). */ + /* translators: %1$s: Post type singular name, %2$s: Post type plural name. */ __( 'Retrieves a single %1$s by ID or queries multiple %2$s with optional filters.' ), $label, $post_type_object->labels->name ?? $post_type_object->label From e0bb52c1b7f53151cb2cb6eb4f8f544f79bac317 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:41:47 +0000 Subject: [PATCH 31/34] improve query schema --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 2dec8a64a6a9c..53bff857e5cab 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -588,6 +588,7 @@ private static function build_date_query_schema(): array { return array( 'type' => 'object', 'description' => __( 'Date query to filter posts by date fields.' ), + 'required' => array( 'queries' ), 'properties' => array( 'relation' => array( 'type' => 'string', @@ -601,7 +602,7 @@ private static function build_date_query_schema(): array { ), 'queries' => array( 'type' => 'array', - 'description' => __( 'List of date query clauses or nested groups.' ), + 'description' => __( 'List of query clauses or nested groups.' ), 'items' => array( 'oneOf' => array( $date_clause_schema, From cd25859a0436b787f5993009c876a7409a65cea7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Sun, 15 Feb 2026 17:42:02 +0000 Subject: [PATCH 32/34] remove trailing comma --- src/wp-includes/abilities/class-wp-post-type-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 53bff857e5cab..44b9356062204 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -1500,7 +1500,7 @@ private static function process_date_top_level( array $input, array &$result ): private static function get_allowed_meta_keys( string $post_type_slug ): array { $registered_meta = array_merge( get_registered_meta_keys( 'post' ), - get_registered_meta_keys( 'post', $post_type_slug ), + get_registered_meta_keys( 'post', $post_type_slug ) ); $allowed = array(); From 26069e1a18cba2baca0f0b1228fcd51d88719742 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 11:17:17 +0000 Subject: [PATCH 33/34] Abilities API: support schema arrays for show_in_abilities meta --- .../class-wp-post-type-abilities.php | 137 ++++++++++++++++-- src/wp-includes/meta.php | 4 +- .../abilities-api/wpPostTypeAbilitiesRest.php | 78 ++++++++++ tests/phpunit/tests/meta/registerMeta.php | 27 ++++ 4 files changed, 230 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-post-type-abilities.php b/src/wp-includes/abilities/class-wp-post-type-abilities.php index 44b9356062204..9bb212f630c12 100644 --- a/src/wp-includes/abilities/class-wp-post-type-abilities.php +++ b/src/wp-includes/abilities/class-wp-post-type-abilities.php @@ -821,6 +821,8 @@ private static function build_post_schema( WP_Post_Type $post_type_object ): arr ); if ( post_type_supports( $slug, 'custom-fields' ) ) { + $meta_properties = self::get_meta_value_schema_properties( $slug ); + $properties['meta'] = array( 'type' => 'object', 'description' => __( 'Public post meta key-value pairs. Only present when include.meta is true.' ), @@ -828,6 +830,10 @@ private static function build_post_schema( WP_Post_Type $post_type_object ): arr 'type' => array( 'array', 'object', 'string', 'number', 'integer', 'boolean', 'null' ), ), ); + + if ( ! empty( $meta_properties ) ) { + $properties['meta']['properties'] = $meta_properties; + } } return array( @@ -1218,13 +1224,9 @@ static function ( $term ): array { } if ( ! empty( $include['meta'] ) && post_type_supports( $slug, 'custom-fields' ) ) { - $meta = get_post_meta( $post->ID ); - $public_meta = array(); - $allowed_meta_keys = self::get_allowed_meta_keys( $slug ); - $registered_meta = array_merge( - get_registered_meta_keys( 'post' ), - get_registered_meta_keys( 'post', $slug ) - ); + $meta = get_post_meta( $post->ID ); + $public_meta = array(); + $allowed_meta = self::get_allowed_meta( $slug ); foreach ( $meta as $key => $values ) { // Skip protected meta keys. @@ -1232,11 +1234,11 @@ static function ( $term ): array { continue; } // Only include meta keys that are explicitly allowed. - if ( ! in_array( $key, $allowed_meta_keys, true ) ) { + if ( ! isset( $allowed_meta[ $key ] ) ) { continue; } // Respect the registered 'single' property for consistent behavior with get_post_meta(). - $is_single = ! empty( $registered_meta[ $key ]['single'] ); + $is_single = ! empty( $allowed_meta[ $key ]['single'] ); $public_meta[ $key ] = $is_single ? ( $values[0] ?? null ) : $values; } @@ -1498,19 +1500,124 @@ private static function process_date_top_level( array $input, array &$result ): * @return string[] List of allowed meta keys. */ private static function get_allowed_meta_keys( string $post_type_slug ): array { - $registered_meta = array_merge( + return array_keys( self::get_allowed_meta( $post_type_slug ) ); + } + + /** + * Returns all registered post meta entries that are exposed through abilities for a post type. + * + * @since 7.0.0 + * + * @param string $post_type_slug The post type slug. + * @return array> Allowed meta registration args keyed by meta key. + */ + private static function get_allowed_meta( string $post_type_slug ): array { + $registered_meta = self::get_registered_meta_for_post_type( $post_type_slug ); + $allowed = array(); + + foreach ( $registered_meta as $key => $args ) { + if ( self::is_meta_enabled_in_abilities( $args ) ) { + $allowed[ $key ] = $args; + } + } + + return $allowed; + } + + /** + * Returns all registered post meta entries for a post type, with subtype values overriding global ones. + * + * @since 7.0.0 + * + * @param string $post_type_slug The post type slug. + * @return array> Registered meta args keyed by meta key. + */ + private static function get_registered_meta_for_post_type( string $post_type_slug ): array { + return array_merge( get_registered_meta_keys( 'post' ), get_registered_meta_keys( 'post', $post_type_slug ) ); + } - $allowed = array(); - foreach ( $registered_meta as $key => $args ) { - if ( ! empty( $args['show_in_abilities'] ) ) { - $allowed[] = $key; + /** + * Determines whether a meta key is enabled for abilities. + * + * `show_in_abilities` can be either a boolean or an options array. + * + * @since 7.0.0 + * + * @param array $args Meta registration args. + * @return bool True if enabled for abilities, false otherwise. + */ + private static function is_meta_enabled_in_abilities( array $args ): bool { + return ! empty( $args['show_in_abilities'] ); + } + + /** + * Builds keyed value schema properties for meta keys that provide `show_in_abilities.schema`. + * + * Keys without a schema continue using the generic additionalProperties fallback. + * + * @since 7.0.0 + * + * @param string $post_type_slug The post type slug. + * @return array> Value schema properties keyed by meta key. + */ + private static function get_meta_value_schema_properties( string $post_type_slug ): array { + $properties = array(); + + foreach ( self::get_allowed_meta( $post_type_slug ) as $key => $args ) { + if ( + ! is_array( $args['show_in_abilities'] ) + || ! isset( $args['show_in_abilities']['schema'] ) + || ! is_array( $args['show_in_abilities']['schema'] ) + ) { + continue; } + + $properties[ $key ] = self::build_meta_value_schema( $args ); + } + + ksort( $properties ); + + return $properties; + } + + /** + * Builds the value schema for a single meta key. + * + * @since 7.0.0 + * + * @param array $args Meta registration args. + * @return array JSON Schema for the meta value. + */ + private static function build_meta_value_schema( array $args ): array { + $schema = array( + 'type' => ! empty( $args['type'] ) ? $args['type'] : 'string', + ); + + if ( ! empty( $args['label'] ) ) { + $schema['title'] = $args['label']; + } + + if ( ! empty( $args['description'] ) ) { + $schema['description'] = $args['description']; + } + + if ( array_key_exists( 'default', $args ) ) { + $schema['default'] = $args['default']; + } + + $schema = array_merge( $schema, $args['show_in_abilities']['schema'] ); + + if ( empty( $args['single'] ) ) { + $schema = array( + 'type' => 'array', + 'items' => $schema, + ); } - return array_unique( $allowed ); + return $schema; } /** diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index ce90084e72652..9081a7782e339 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -1425,7 +1425,9 @@ function sanitize_meta( $meta_key, $meta_value, $object_type, $object_subtype = * support for custom fields for registered meta to be accessible via REST. * When registering complex meta values this argument may optionally be an * array with 'schema' or 'prepare_callback' keys instead of a boolean. - * @type bool $show_in_abilities Whether this meta key should be exposed through the Abilities API. + * @type bool|array $show_in_abilities Whether this meta key should be exposed through the Abilities API. + * When registering complex meta values this argument may optionally be an + * array with a 'schema' key instead of a boolean. * Default false. * @type bool $revisions_enabled Whether to enable revisions support for this meta_key. Can only be used when the * object type is 'post'. diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php index 3ff2337273ad2..6eda134aacaf7 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php @@ -172,6 +172,7 @@ public static function wpTearDownAfterClass(): void { unregister_meta_key( 'post', 'c' ); unregister_meta_key( 'post', 'My.Key' ); unregister_meta_key( 'post', 'structured_object' ); + unregister_meta_key( 'post', 'schema_constrained' ); } /** @@ -237,6 +238,20 @@ public function set_up(): void { 'show_in_abilities' => true, ) ); + register_meta( + 'post', + 'schema_constrained', + array( + 'type' => 'string', + 'single' => true, + 'show_in_abilities' => array( + 'schema' => array( + 'type' => 'string', + 'enum' => array( 'allowed' ), + ), + ), + ) + ); $this->reregister_post_type_abilities(); } @@ -722,6 +737,69 @@ public function test_meta_only_includes_show_in_abilities_registered_keys(): voi $this->assertArrayNotHasKey( 'x', (array) $data['meta'] ); } + /** + * Tests that a schema-based `show_in_abilities` registration is accepted for meta queries. + * + * @ticket 64606 + */ + public function test_meta_query_with_schema_based_registration_succeeds(): void { + update_post_meta( self::$post_ids[1], 'schema_constrained', 'allowed' ); + + $response = $this->dispatch_get_ability( + array( + 'query' => array( + 'meta' => array( + 'queries' => array( + array( + 'key' => 'schema_constrained', + 'compare' => '=', + 'value' => 'allowed', + ), + ), + ), + ), + 'per_page' => 100, + ) + ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $post_ids = $this->get_response_post_ids( $data ); + + $this->assertContains( self::$post_ids[1], $post_ids ); + } + + /** + * Tests that `show_in_abilities.schema` is enforced when meta is included in output. + * + * @ticket 64606 + */ + public function test_meta_include_with_schema_invalid_value_fails_output_validation(): void { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Post with invalid schema-constrained meta', + 'post_status' => 'publish', + ) + ); + update_post_meta( $post_id, 'schema_constrained', 'blocked' ); + + $response = $this->dispatch_get_ability( + array( + 'id' => $post_id, + 'include' => array( 'meta' => true ), + ) + ); + + wp_delete_post( $post_id, true ); + + $this->assertSame( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'ability_invalid_output', $data['code'] ); + $this->assertStringContainsString( 'schema_constrained', $data['message'] ); + $this->assertStringContainsString( 'output', $data['message'] ); + } + /** * Tests that object-like meta values are allowed by the output schema. * diff --git a/tests/phpunit/tests/meta/registerMeta.php b/tests/phpunit/tests/meta/registerMeta.php index 8ce13e5ead420..45360bc351bd3 100644 --- a/tests/phpunit/tests/meta/registerMeta.php +++ b/tests/phpunit/tests/meta/registerMeta.php @@ -80,6 +80,33 @@ public function test_register_meta_with_post_object_type_returns_true() { $this->assertTrue( $result ); } + public function test_register_meta_with_show_in_abilities_schema() { + register_meta( + 'post', + 'flight_number', + array( + 'show_in_abilities' => array( + 'schema' => array( + 'type' => 'string', + 'enum' => array( 'Oceanic 815' ), + ), + ), + ) + ); + $meta_keys = get_registered_meta_keys( 'post' ); + unregister_meta_key( 'post', 'flight_number' ); + + $this->assertSame( + array( + 'schema' => array( + 'type' => 'string', + 'enum' => array( 'Oceanic 815' ), + ), + ), + $meta_keys['flight_number']['show_in_abilities'] + ); + } + public function test_register_meta_with_post_object_type_populates_wp_meta_keys() { global $wp_meta_keys; From 4a23e9828241fcb182f1b8c24682005db60e805b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 11:31:44 +0000 Subject: [PATCH 34/34] update tests to test agsinst the ability directly --- ...litiesRest.php => wpPostTypeAbilities.php} | 218 +++++++----------- 1 file changed, 86 insertions(+), 132 deletions(-) rename tests/phpunit/tests/abilities-api/{wpPostTypeAbilitiesRest.php => wpPostTypeAbilities.php} (80%) diff --git a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php b/tests/phpunit/tests/abilities-api/wpPostTypeAbilities.php similarity index 80% rename from tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php rename to tests/phpunit/tests/abilities-api/wpPostTypeAbilities.php index 6eda134aacaf7..76c3b91fefb3f 100644 --- a/tests/phpunit/tests/abilities-api/wpPostTypeAbilitiesRest.php +++ b/tests/phpunit/tests/abilities-api/wpPostTypeAbilities.php @@ -3,7 +3,7 @@ declare( strict_types=1 ); /** - * Tests for the post type get ability via the REST API. + * Tests for the post type get ability. * * @ticket 64606 * @@ -11,12 +11,7 @@ * * @group abilities-api */ -class Tests_Abilities_API_WpPostTypeAbilitiesRest extends WP_Test_REST_TestCase { - - /** - * REST API route for the post get ability. - */ - private const ROUTE = '/wp-abilities/v1/abilities/core/post-type/post/get/run'; +class Tests_Abilities_API_WpPostTypeAbilities extends WP_UnitTestCase { /** * Editor user ID. @@ -290,17 +285,20 @@ private function reregister_post_type_abilities(): void { } /** - * Dispatches a GET request to the post type get ability endpoint. + * Executes the post type get ability. * * @param array $input Input parameters for the ability. - * @return WP_REST_Response The response. + * @return mixed The ability output on success, or WP_Error on failure. */ - private function dispatch_get_ability( array $input = array() ): WP_REST_Response { - $request = new WP_REST_Request( 'GET', self::ROUTE ); - if ( ! empty( $input ) ) { - $request->set_query_params( array( 'input' => $input ) ); + private function execute_get_ability( array $input = array() ) { + $ability = wp_get_ability( 'core/post-type/post/get' ); + $this->assertInstanceOf( WP_Ability::class, $ability ); + + if ( empty( $input ) ) { + return $ability->execute(); } - return rest_get_server()->dispatch( $request ); + + return $ability->execute( $input ); } /** @@ -319,31 +317,20 @@ static function ( $post ) { } /** - * Tests that the ability run route is registered. + * Tests that the get ability is registered with expected metadata. * * @ticket 64606 */ - public function test_route_is_registered(): void { - $routes = rest_get_server()->get_routes(); - // The route pattern covers all ability names including this one. - $this->assertArrayHasKey( - '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', - $routes - ); - } + public function test_get_ability_is_registered_with_expected_metadata(): void { + $ability = wp_get_ability( 'core/post-type/post/get' ); - /** - * Tests that POST method is rejected for this readonly ability. - * - * @ticket 64606 - */ - public function test_post_method_rejected(): void { - $request = new WP_REST_Request( 'POST', self::ROUTE ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); - $response = rest_get_server()->dispatch( $request ); + $this->assertInstanceOf( WP_Ability::class, $ability ); - $this->assertSame( 405, $response->get_status() ); + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + $this->assertTrue( $annotations['readonly'] ?? false ); + $this->assertFalse( $annotations['destructive'] ?? true ); + $this->assertTrue( $annotations['idempotent'] ?? false ); } /** @@ -352,11 +339,9 @@ public function test_post_method_rejected(): void { * @ticket 64606 */ public function test_get_single_post_by_id(): void { - $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); - - $this->assertSame( 200, $response->get_status() ); + $data = $this->execute_get_ability( array( 'id' => self::$post_ids[1] ) ); - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertSame( self::$post_ids[1], $data['id'] ); $this->assertSame( 'post', $data['type'] ); $this->assertSame( 'publish', $data['status'] ); @@ -373,7 +358,7 @@ public function test_get_single_post_by_id(): void { * @ticket 64606 */ public function test_get_single_post_with_meta_and_taxonomies(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'id' => self::$post_ids[1], 'include' => array( @@ -383,9 +368,7 @@ public function test_get_single_post_with_meta_and_taxonomies(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); // Meta should contain the public meta keys. $this->assertArrayHasKey( 'meta', $data ); @@ -404,14 +387,15 @@ public function test_get_single_post_with_meta_and_taxonomies(): void { } /** - * Tests that requesting a non-existent post returns 404. + * Tests that requesting a non-existent post returns a not found error. * * @ticket 64606 */ public function test_get_single_post_not_found(): void { - $response = $this->dispatch_get_ability( array( 'id' => 999999 ) ); + $result = $this->execute_get_ability( array( 'id' => 999999 ) ); - $this->assertSame( 404, $response->get_status() ); + $this->assertWPError( $result ); + $this->assertSame( 'post_not_found', $result->get_error_code() ); } /** @@ -420,16 +404,14 @@ public function test_get_single_post_not_found(): void { * @ticket 64606 */ public function test_query_returns_paginated_results(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'per_page' => 2, 'page' => 1, ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertArrayHasKey( 'posts', $data ); $this->assertCount( 2, $data['posts'] ); $this->assertSame( 6, $data['total'] ); @@ -442,7 +424,7 @@ public function test_query_returns_paginated_results(): void { * @ticket 64606 */ public function test_meta_query_exists(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'include' => array( 'meta' => true ), 'query' => array( @@ -458,9 +440,7 @@ public function test_meta_query_exists(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertSame( 1, $data['total'] ); @@ -475,7 +455,7 @@ public function test_meta_query_exists(): void { * @ticket 64606 */ public function test_meta_query_nested_and_or(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -507,9 +487,7 @@ public function test_meta_query_nested_and_or(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertSame( 2, $data['total'] ); @@ -525,7 +503,7 @@ public function test_meta_query_nested_and_or(): void { * @ticket 64606 */ public function test_tax_query_nested_and_or(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'tax' => array( @@ -557,9 +535,7 @@ public function test_tax_query_nested_and_or(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertSame( 2, $data['total'] ); @@ -576,7 +552,7 @@ public function test_tax_query_nested_and_or(): void { * @ticket 64606 */ public function test_date_query_nested_and_or(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'per_page' => 100, 'query' => array( @@ -597,9 +573,7 @@ public function test_date_query_nested_and_or(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertSame( 5, $data['total'] ); @@ -619,9 +593,10 @@ public function test_date_query_nested_and_or(): void { public function test_unauthenticated_query_published_posts_rejected(): void { wp_set_current_user( 0 ); - $response = $this->dispatch_get_ability( array() ); + $result = $this->execute_get_ability( array() ); - $this->assertContains( $response->get_status(), array( 401, 403 ) ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } /** @@ -632,14 +607,14 @@ public function test_unauthenticated_query_published_posts_rejected(): void { public function test_unauthenticated_query_private_status_rejected(): void { wp_set_current_user( 0 ); - $response = $this->dispatch_get_ability( + $result = $this->execute_get_ability( array( 'status' => array( 'private' ), ) ); - $this->assertContains( $response->get_status(), array( 401, 403 ) ); - $this->assertSame( 'rest_ability_cannot_execute', $response->get_data()['code'] ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } /** @@ -650,9 +625,10 @@ public function test_unauthenticated_query_private_status_rejected(): void { public function test_unauthenticated_get_single_published_post_rejected(): void { wp_set_current_user( 0 ); - $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[1] ) ); + $result = $this->execute_get_ability( array( 'id' => self::$post_ids[1] ) ); - $this->assertContains( $response->get_status(), array( 401, 403 ) ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } /** @@ -663,9 +639,10 @@ public function test_unauthenticated_get_single_published_post_rejected(): void public function test_unauthenticated_get_single_private_post_rejected(): void { wp_set_current_user( 0 ); - $response = $this->dispatch_get_ability( array( 'id' => self::$post_ids[7] ) ); + $result = $this->execute_get_ability( array( 'id' => self::$post_ids[7] ) ); - $this->assertContains( $response->get_status(), array( 401, 403 ) ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } /** @@ -674,11 +651,9 @@ public function test_unauthenticated_get_single_private_post_rejected(): void { * @ticket 64606 */ public function test_authenticated_query_succeeds(): void { - $response = $this->dispatch_get_ability( array() ); - - $this->assertSame( 200, $response->get_status() ); + $data = $this->execute_get_ability( array() ); - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertArrayHasKey( 'posts', $data ); $this->assertGreaterThan( 0, $data['total'] ); } @@ -689,7 +664,7 @@ public function test_authenticated_query_succeeds(): void { * @ticket 64606 */ public function test_query_with_ordering(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'order' => array( 'orderby' => 'title', @@ -699,9 +674,7 @@ public function test_query_with_ordering(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $titles = array_map( static function ( $post ) { return $post['title']; @@ -721,16 +694,14 @@ static function ( $post ) { */ public function test_meta_only_includes_show_in_abilities_registered_keys(): void { // Post 4 has meta key 'x' which is NOT registered with show_in_abilities. - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'id' => self::$post_ids[4], 'include' => array( 'meta' => true ), ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertArrayHasKey( 'meta', $data ); // The 'x' meta key should NOT be present since it's not registered with show_in_abilities. @@ -745,7 +716,7 @@ public function test_meta_only_includes_show_in_abilities_registered_keys(): voi public function test_meta_query_with_schema_based_registration_succeeds(): void { update_post_meta( self::$post_ids[1], 'schema_constrained', 'allowed' ); - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -762,9 +733,7 @@ public function test_meta_query_with_schema_based_registration_succeeds(): void ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertContains( self::$post_ids[1], $post_ids ); @@ -784,7 +753,7 @@ public function test_meta_include_with_schema_invalid_value_fails_output_validat ); update_post_meta( $post_id, 'schema_constrained', 'blocked' ); - $response = $this->dispatch_get_ability( + $result = $this->execute_get_ability( array( 'id' => $post_id, 'include' => array( 'meta' => true ), @@ -793,11 +762,10 @@ public function test_meta_include_with_schema_invalid_value_fails_output_validat wp_delete_post( $post_id, true ); - $this->assertSame( 500, $response->get_status() ); - $data = $response->get_data(); - $this->assertSame( 'ability_invalid_output', $data['code'] ); - $this->assertStringContainsString( 'schema_constrained', $data['message'] ); - $this->assertStringContainsString( 'output', $data['message'] ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_output', $result->get_error_code() ); + $this->assertStringContainsString( 'schema_constrained', $result->get_error_message() ); + $this->assertStringContainsString( 'output', $result->get_error_message() ); } /** @@ -825,7 +793,7 @@ public function test_meta_include_supports_object_values(): void { add_filter( 'get_post_metadata', $filter, 10, 5 ); - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'id' => self::$post_ids[1], 'include' => array( 'meta' => true ), @@ -833,9 +801,7 @@ public function test_meta_include_supports_object_values(): void { ); remove_filter( 'get_post_metadata', $filter, 10 ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertArrayHasKey( 'meta', $data ); $meta = (array) $data['meta']; @@ -852,7 +818,7 @@ public function test_meta_include_supports_object_values(): void { * @ticket 64606 */ public function test_meta_query_with_invalid_key_returns_error(): void { - $response = $this->dispatch_get_ability( + $result = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -867,11 +833,9 @@ public function test_meta_query_with_invalid_key_returns_error(): void { ) ); - $this->assertSame( 400, $response->get_status() ); - - $data = $response->get_data(); + $this->assertWPError( $result ); // Schema validation catches invalid meta keys. - $this->assertSame( 'ability_invalid_input', $data['code'] ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); } /** @@ -880,7 +844,7 @@ public function test_meta_query_with_invalid_key_returns_error(): void { * @ticket 64606 */ public function test_meta_query_with_valid_key_succeeds(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -896,9 +860,7 @@ public function test_meta_query_with_valid_key_succeeds(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertArrayHasKey( 'posts', $data ); } @@ -908,7 +870,7 @@ public function test_meta_query_with_valid_key_succeeds(): void { * @ticket 64606 */ public function test_meta_query_with_non_slug_registered_key_succeeds(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -924,9 +886,7 @@ public function test_meta_query_with_non_slug_registered_key_succeeds(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertSame( 1, $data['total'] ); @@ -963,7 +923,7 @@ public function test_meta_query_respects_post_type_specific_show_in_abilities_ov update_post_meta( self::$post_ids[1], 'override_key', '1' ); $this->reregister_post_type_abilities(); - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -979,9 +939,7 @@ public function test_meta_query_respects_post_type_specific_show_in_abilities_ov ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $post_ids = $this->get_response_post_ids( $data ); $this->assertSame( 1, $data['total'] ); @@ -994,7 +952,7 @@ public function test_meta_query_respects_post_type_specific_show_in_abilities_ov * @ticket 64606 */ public function test_meta_query_deep_nested_invalid_clause_rejected(): void { - $response = $this->dispatch_get_ability( + $result = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -1018,8 +976,8 @@ public function test_meta_query_deep_nested_invalid_clause_rejected(): void { ) ); - $this->assertSame( 400, $response->get_status() ); - $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); } /** @@ -1028,7 +986,7 @@ public function test_meta_query_deep_nested_invalid_clause_rejected(): void { * @ticket 64606 */ public function test_meta_query_missing_queries_rejected(): void { - $response = $this->dispatch_get_ability( + $result = $this->execute_get_ability( array( 'query' => array( 'meta' => array( @@ -1038,8 +996,8 @@ public function test_meta_query_missing_queries_rejected(): void { ) ); - $this->assertSame( 400, $response->get_status() ); - $this->assertSame( 'ability_invalid_input', $response->get_data()['code'] ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); } /** @@ -1057,7 +1015,7 @@ public function test_tax_query_with_non_public_taxonomy_returns_error(): void { ) ); - $response = $this->dispatch_get_ability( + $result = $this->execute_get_ability( array( 'query' => array( 'tax' => array( @@ -1072,11 +1030,9 @@ public function test_tax_query_with_non_public_taxonomy_returns_error(): void { ) ); - $this->assertSame( 400, $response->get_status() ); - - $data = $response->get_data(); // Schema validation catches non-public taxonomies. - $this->assertSame( 'ability_invalid_input', $data['code'] ); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); // Clean up. unregister_taxonomy( 'private_tax' ); @@ -1088,7 +1044,7 @@ public function test_tax_query_with_non_public_taxonomy_returns_error(): void { * @ticket 64606 */ public function test_tax_query_with_public_taxonomy_succeeds(): void { - $response = $this->dispatch_get_ability( + $data = $this->execute_get_ability( array( 'query' => array( 'tax' => array( @@ -1104,9 +1060,7 @@ public function test_tax_query_with_public_taxonomy_succeeds(): void { ) ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertArrayHasKey( 'posts', $data ); } }