Skip to content

Commit 27a0d86

Browse files
committed
Abilities API: Refine filtering and expose meta over REST.
Follow-up to [62420]. The `category` argument now takes a single slug, matching `namespace`; callers needing multiple values can use `item_include_callback`. This keeps the public surface simple and leaves room to accept arrays later without a break. The list endpoint (`/wp-abilities/v1/abilities`) gains a `meta` query parameter alongside `category` and `namespace`. Conditions combine with AND logic, may be nested, and known annotations (`readonly`, `destructive`, `idempotent`) are coerced to booleans before matching. The endpoint always forces `meta[show_in_rest] => true`, so `meta` cannot reveal hidden abilities. Props gziolo, jorgefilipecosta, apermo. Fixes #64990. git-svn-id: https://develop.svn.wordpress.org/trunk@62548 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 36cc6e9 commit 27a0d86

5 files changed

Lines changed: 337 additions & 16 deletions

File tree

src/wp-includes/abilities-api.php

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ function wp_get_ability( string $name ): ?WP_Ability {
404404
* Filtering pipeline (executed in order):
405405
*
406406
* 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between
407-
* arg types, OR logic within multi-value `category` arrays.
407+
* arg types.
408408
* 2. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude.
409409
* 3. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce
410410
* universal inclusion rules regardless of what the caller passed.
@@ -421,9 +421,6 @@ function wp_get_ability( string $name ): ?WP_Ability {
421421
* // Filter by category.
422422
* $abilities = wp_get_abilities( array( 'category' => 'content' ) );
423423
*
424-
* // Filter by multiple categories (OR logic).
425-
* $abilities = wp_get_abilities( array( 'category' => array( 'content', 'settings' ) ) );
426-
*
427424
* // Filter by namespace.
428425
* $abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) );
429426
*
@@ -466,9 +463,8 @@ function wp_get_ability( string $name ): ?WP_Ability {
466463
* @param array $args {
467464
* Optional. Arguments to filter the returned abilities. Default empty array (returns all).
468465
*
469-
* @type string|string[] $category Filter by category slug. A single string or an array of
470-
* slugs — abilities matching any of the given slugs are
471-
* included (OR logic within this arg type).
466+
* @type string $category Filter by category slug. Only abilities whose category
467+
* exactly matches the given slug are included.
472468
* @type string $namespace Filter by ability namespace prefix. Pass the namespace
473469
* without a trailing slash, e.g. `'woocommerce'` matches
474470
* `'woocommerce/create-order'`.
@@ -495,7 +491,7 @@ function wp_get_abilities( array $args = array() ): array {
495491

496492
$abilities = $registry->get_all_registered();
497493

498-
$category = isset( $args['category'] ) ? (array) $args['category'] : array();
494+
$category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : '';
499495
$namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : '';
500496
$meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array();
501497
$item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null;
@@ -504,8 +500,8 @@ function wp_get_abilities( array $args = array() ): array {
504500
$matched = array();
505501

506502
foreach ( $abilities as $name => $ability ) {
507-
// Step 1a: Filter by category (OR logic within the arg).
508-
if ( ! empty( $category ) && ! in_array( $ability->get_category(), $category, true ) ) {
503+
// Step 1a: Filter by category.
504+
if ( '' !== $category && $ability->get_category() !== $category ) {
509505
continue;
510506
}
511507

src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ public function get_items( $request ) {
9898
$query_args['namespace'] = $request['namespace'];
9999
}
100100

101+
if ( ! empty( $request['meta'] ) ) {
102+
// Merge caller meta first so the forced show_in_rest filter wins. This keeps a caller from using meta to reveal abilities hidden from REST.
103+
$query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] );
104+
}
105+
101106
$abilities = wp_get_abilities( $query_args );
102107

103108
$page = $request['page'];
@@ -447,9 +452,23 @@ public function get_item_schema(): array {
447452
'type' => 'object',
448453
'properties' => array(
449454
'annotations' => array(
450-
'description' => __( 'Annotations for the ability.' ),
451-
'type' => array( 'boolean', 'null' ),
452-
'default' => null,
455+
'description' => __( 'Behavioral annotations for the ability.' ),
456+
'type' => 'object',
457+
'properties' => array(
458+
'readonly' => array(
459+
'description' => __( 'Whether the ability does not modify its environment.' ),
460+
'type' => array( 'boolean', 'null' ),
461+
),
462+
'destructive' => array(
463+
'description' => __( 'Whether the ability may perform destructive updates to its environment.' ),
464+
'type' => array( 'boolean', 'null' ),
465+
),
466+
'idempotent' => array(
467+
'description' => __( 'Whether repeated calls with the same arguments have no additional effect.' ),
468+
'type' => array( 'boolean', 'null' ),
469+
),
470+
),
471+
'additionalProperties' => true,
453472
),
454473
),
455474
'context' => array( 'view', 'edit' ),
@@ -469,7 +488,7 @@ public function get_item_schema(): array {
469488
* @return array<string, mixed> Collection parameters.
470489
*/
471490
public function get_collection_params(): array {
472-
return array(
491+
$query_params = array(
473492
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
474493
'page' => array(
475494
'description' => __( 'Current page of the collection.' ),
@@ -496,6 +515,46 @@ public function get_collection_params(): array {
496515
'sanitize_callback' => 'sanitize_key',
497516
'validate_callback' => 'rest_validate_request_arg',
498517
),
518+
'meta' => array(
519+
'description' => __( 'Limit results to abilities matching all of the given meta fields.' ),
520+
'type' => 'object',
521+
'properties' => array(
522+
// show_in_rest is omitted on purpose. It is forced on and cannot be filtered by a caller.
523+
'annotations' => array(
524+
'description' => __( 'Limit results to abilities matching the given behavioral annotations.' ),
525+
'type' => 'object',
526+
'properties' => array(
527+
'readonly' => array(
528+
'description' => __( 'Whether the ability does not modify its environment.' ),
529+
'type' => array( 'boolean', 'null' ),
530+
),
531+
'destructive' => array(
532+
'description' => __( 'Whether the ability may perform destructive updates to its environment.' ),
533+
'type' => array( 'boolean', 'null' ),
534+
),
535+
'idempotent' => array(
536+
'description' => __( 'Whether repeated calls with the same arguments have no additional effect.' ),
537+
'type' => array( 'boolean', 'null' ),
538+
),
539+
),
540+
'additionalProperties' => true,
541+
),
542+
),
543+
'additionalProperties' => true,
544+
),
499545
);
546+
547+
/**
548+
* Filters REST API collection parameters for the abilities controller.
549+
*
550+
* Use this to declare the schema type of a custom meta key. A declared
551+
* type lets REST coerce a query-string value, for example "true" to a
552+
* boolean, before the meta filter matches it.
553+
*
554+
* @since 7.1.0
555+
*
556+
* @param array $query_params JSON Schema-formatted collection parameters.
557+
*/
558+
return apply_filters( 'rest_abilities_collection_params', $query_params );
500559
}
501560
}

tests/phpunit/tests/abilities-api/wpGetAbilities.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ public function set_up(): void {
3939
'description' => 'Text operations.',
4040
)
4141
);
42+
wp_register_ability_category(
43+
'media',
44+
array(
45+
'label' => 'Media',
46+
'description' => 'Media operations.',
47+
)
48+
);
4249
}
4350

4451
/**
@@ -53,6 +60,7 @@ public function tear_down(): void {
5360

5461
wp_unregister_ability_category( 'math' );
5562
wp_unregister_ability_category( 'text' );
63+
wp_unregister_ability_category( 'media' );
5664

5765
parent::tear_down();
5866
}
@@ -135,15 +143,19 @@ static function ( WP_Ability $a ) {
135143
}
136144

137145
/**
138-
* Tests that passing an array of categories uses OR logic.
146+
* Tests that a non-string category is ignored rather than treated as a multi-value filter.
147+
*
148+
* The declarative filters accept a single slug. Anything other than a string is ignored,
149+
* matching how the `namespace` and `meta` args guard their own types.
139150
*
140151
* @ticket 64990
141152
*/
142-
public function test_filter_by_category_array_uses_or_logic(): void {
153+
public function test_filter_by_non_string_category_is_ignored(): void {
143154
$this->simulate_wp_abilities_init();
144155

145156
$this->register_test_ability( 'test/math-add', array( 'category' => 'math' ) );
146157
$this->register_test_ability( 'test/text-upper', array( 'category' => 'text' ) );
158+
$this->register_test_ability( 'test/media-crop', array( 'category' => 'media' ) );
147159

148160
$result = wp_get_abilities( array( 'category' => array( 'math', 'text' ) ) );
149161
$names = array_map(
@@ -155,6 +167,7 @@ static function ( WP_Ability $a ) {
155167

156168
$this->assertContains( 'test/math-add', $names );
157169
$this->assertContains( 'test/text-upper', $names );
170+
$this->assertContains( 'test/media-crop', $names );
158171
}
159172

160173
/**

0 commit comments

Comments
 (0)