Skip to content
192 changes: 180 additions & 12 deletions src/wp-includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -396,33 +396,201 @@ function wp_get_ability( string $name ): ?WP_Ability {
}

/**
* Retrieves all registered abilities.
* Retrieves registered abilities, optionally filtered by the given arguments.
*
* Returns an array of all ability instances currently registered in the system.
* Use this for discovery, debugging, or building administrative interfaces.
* When called without arguments, returns all registered abilities. When called
* with an $args array, returns only abilities that match every specified condition.
*
* Example:
* Filtering pipeline (executed in order):
*
* 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between
* arg types, OR logic within multi-value `category` arrays.
* 2. `match_callback` — per-item, caller-scoped. Return true to include, false to exclude.
* 3. `wp_get_abilities_match` filter — per-item, ecosystem-scoped. Plugins can enforce
* universal inclusion rules regardless of what the caller passed.
* 4. `result_callback` — on the full matched array, caller-scoped. Sort, slice, or reshape.
* 5. `wp_get_abilities_result` filter — on the full array, ecosystem-scoped.
*
* Steps 1–3 run inside a single loop over the registry — no extra iteration.
*
* Examples:
*
* // Prints information about all available abilities.
* // All abilities (unchanged behaviour).
* $abilities = wp_get_abilities();
* foreach ( $abilities as $ability ) {
* echo $ability->get_label() . ': ' . $ability->get_description() . "\n";
* }
*
* // Filter by category.
* $abilities = wp_get_abilities( array( 'category' => 'content' ) );
*
* // Filter by multiple categories (OR logic).
* $abilities = wp_get_abilities( array( 'category' => array( 'content', 'settings' ) ) );
*
* // Filter by namespace.
* $abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) );
*
* // Filter by meta.
* $abilities = wp_get_abilities( array( 'meta' => array( 'show_in_rest' => true ) ) );
*
* // Combine filters (AND logic between arg types).
* $abilities = wp_get_abilities( array(
* 'category' => 'content',
* 'namespace' => 'core',
* 'meta' => array( 'show_in_rest' => true ),
* ) );
*
* // Caller-scoped per-item callback.
* $abilities = wp_get_abilities( array(
* 'match_callback' => function ( WP_Ability $ability ) {
* return current_user_can( 'manage_options' );
* },
* ) );
*
* // Caller-scoped result callback (sort + paginate).
* $abilities = wp_get_abilities( array(
* 'result_callback' => function ( array $abilities ) {
* usort( $abilities, fn( $a, $b ) => strcasecmp( $a->get_label(), $b->get_label() ) );
* return array_slice( $abilities, 0, 10 );
* },
* ) );
*
* @since 6.9.0
* @since 7.1.0 Added the `$args` parameter for filtering support.
*
* @see WP_Abilities_Registry::get_all_registered()
*
* @return WP_Ability[] An array of registered WP_Ability instances. Returns an empty
* array if no abilities are registered or if the registry is unavailable.
* @param array $args {
* Optional. Arguments to filter the returned abilities. Default empty array (returns all).
*
* @type string|string[] $category Filter by category slug. A single string or an array of
* slugs — abilities matching any of the given slugs are
* included (OR logic within this arg type).
* @type string $namespace Filter by ability namespace prefix. Pass the namespace
* without a trailing slash, e.g. `'woocommerce'` matches
* `'woocommerce/create-order'`.
* @type array $meta Filter by meta key/value pairs. All conditions must
* match (AND logic). Supports nested arrays for structured
* meta, e.g. `array( 'mcp' => array( 'public' => true ) )`.
* @type callable $match_callback Optional. A callback invoked per ability after declarative
* filters. Receives a WP_Ability instance, returns bool.
* Return true to include, false to exclude.
* @type callable $result_callback Optional. A callback invoked once on the full matched
* array. Receives WP_Ability[], must return WP_Ability[].
* Use for sorting, slicing, or reshaping the result.
* }
* @return WP_Ability[] An array of registered WP_Ability instances matching the given args.
* Returns an empty array if no abilities are registered, the registry is
* unavailable, or no abilities match the given args.
*/
function wp_get_abilities(): array {
function wp_get_abilities( array $args = array() ): array {
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return array();
}

return $registry->get_all_registered();
$abilities = $registry->get_all_registered();

// Bail early when no filtering is requested.
if ( empty( $args ) ) {
return $abilities;
}

$category = isset( $args['category'] ) ? (array) $args['category'] : array();
$namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : '';
$meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array();
$match_callback = isset( $args['match_callback'] ) && is_callable( $args['match_callback'] ) ? $args['match_callback'] : null;
$result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null;
Comment on lines +496 to +500
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is worth double-checking with other similar functions to see whether silently dropping args with incorrect types is the established best practice.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked against similar WordPress query functions — get_posts(), get_terms(), and WP_Query all silently cast or ignore args with incorrect types without triggering warnings. For example, get_posts() accepts 'category' as either a string or an integer and handles both without complaint. Our implementation follows the same established pattern: category is cast via (array), which safely handles both string and array inputs, while namespace, match_callback, and result_callback are guarded with is_string()/is_callable() checks and silently fall back to their defaults when the type is wrong.

If stricter developer feedback is desired, _doing_it_wrong() calls could be added in a follow-up, but silent dropping/casting is consistent with how WordPress core query functions have always behaved.


$matched = array();

foreach ( $abilities as $ability ) {
// Step 1a: Filter by category (OR logic within the arg).
if ( ! empty( $category ) && ! in_array( $ability->get_category(), $category, true ) ) {
continue;
}

// Step 1b: Filter by namespace prefix.
if ( '' !== $namespace && ! str_starts_with( $ability->get_name(), $namespace ) ) {
continue;
}

// Step 1c: Filter by meta key/value pairs (AND logic, supports nested arrays).
if ( ! empty( $meta ) && ! wp_get_abilities_match_meta( $ability->get_meta(), $meta ) ) {
continue;
}

// Step 2: Caller-scoped per-item callback.
$include = true;
if ( null !== $match_callback ) {
$include = (bool) call_user_func( $match_callback, $ability );
}

/**
* Filters whether an individual ability should be included in the result set.
*
* Fires after the declarative filters and the caller-scoped match_callback.
* Plugins can use this to enforce universal inclusion rules regardless of
* what the caller passed in $args.
*
* @since 7.1.0
*
* @param bool $include Whether to include the ability. Default true (after declarative filters pass).
* @param WP_Ability $ability The ability instance being evaluated.
* @param array $args The full $args array passed to wp_get_abilities().
*/
$include = (bool) apply_filters( 'wp_get_abilities_match', $include, $ability, $args );

if ( $include ) {
$matched[] = $ability;
}
}

// Step 4: Caller-scoped result callback.
if ( null !== $result_callback ) {
$matched = (array) call_user_func( $result_callback, $matched );
}

/**
* Filters the full list of matched abilities after all per-item filtering is complete.
*
* Fires after the caller-scoped result_callback. Plugins can use this to sort,
* paginate, or reshape the final result set universally.
*
* @since 7.1.0
*
* @param WP_Ability[] $matched The matched abilities after all filtering.
* @param array $args The full $args array passed to wp_get_abilities().
*/
return (array) apply_filters( 'wp_get_abilities_result', $matched, $args );
}

/**
* Checks whether an ability's meta array matches a set of required key/value conditions.
*
* All conditions must match (AND logic). Supports nested arrays for structured meta,
* e.g. `array( 'mcp' => array( 'public' => true ) )`.
*
* @since 7.1.0
* @access private
*
* @param array $meta The ability's meta array.
* @param array $conditions The required key/value conditions to match against.
* @return bool True if all conditions match, false otherwise.
*/
function wp_get_abilities_match_meta( array $meta, array $conditions ): bool {
Comment thread
gziolo marked this conversation as resolved.
Outdated
foreach ( $conditions as $key => $value ) {
if ( ! array_key_exists( $key, $meta ) ) {
return false;
}

if ( is_array( $value ) ) {
if ( ! is_array( $meta[ $key ] ) || ! wp_get_abilities_match_meta( $meta[ $key ], $value ) ) {
return false;
}
} elseif ( $meta[ $key ] !== $value ) {
return false;
}
}

return true;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,20 @@ public function register_routes(): void {
* @return WP_REST_Response Response object on success.
*/
public function get_items( $request ) {
$abilities = array_filter(
wp_get_abilities(),
static function ( $ability ) {
return $ability->get_meta_item( 'show_in_rest' );
}
$query_args = array(
'meta' => array( 'show_in_rest' => true ),
);

// Filter by ability category if specified.
$category = $request['category'];
if ( ! empty( $category ) ) {
$abilities = array_filter(
$abilities,
static function ( $ability ) use ( $category ) {
return $ability->get_category() === $category;
}
);
// Reset array keys after filtering.
$abilities = array_values( $abilities );
if ( ! empty( $request['category'] ) ) {
$query_args['category'] = $request['category'];
}

if ( ! empty( $request['namespace'] ) ) {
$query_args['namespace'] = $request['namespace'];
}

$abilities = wp_get_abilities( $query_args );

$page = $request['page'];
$per_page = $request['per_page'];
$offset = ( $page - 1 ) * $per_page;
Expand Down Expand Up @@ -432,12 +426,18 @@ public function get_collection_params(): array {
'minimum' => 1,
'maximum' => 100,
),
'category' => array(
'category' => array(
'description' => __( 'Limit results to abilities in specific ability category.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
'namespace' => array(
'description' => __( 'Limit results to abilities in a specific namespace.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
);
}
}
5 changes: 5 additions & 0 deletions tests/qunit/fixtures/wp-api-generated.js
Original file line number Diff line number Diff line change
Expand Up @@ -12582,6 +12582,11 @@ mockedApiResponse.Schema = {
"description": "Limit results to abilities in specific ability category.",
"type": "string",
"required": false
},
"namespace": {
"description": "Limit results to abilities in a specific namespace.",
"type": "string",
"required": false
}
}
}
Expand Down
Loading