-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Feature #64990: Add filtering support to wp_get_abilities() via $args array
#11531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
643e405
0e6f5cf
0350d8e
ba95508
38fec64
4a58fdd
19a528e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for providing the clarification and references, some code examples for documentation purposes: |
||||||||||
|
|
||||||||||
| $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 ); | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Naming nit worth resolving while the API is still pre-merge: this filter is I'd vote we pick include and align everything to it. The action ("should this be included?") reads more naturally than the predicate ("did it match?"), and it avoids the cognitive collision with PHP 8's Concrete proposal:
The If we go this route, the rename cascades to:
|
||||||||||
|
|
||||||||||
| 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 { | ||||||||||
| 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; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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']; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The refactor itself preserves existing behavior — The gap is the new
Suggest adding three tests in public function test_filter_by_namespace(): void {
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
$request->set_param( 'namespace', 'test' );
$response = $this->server->dispatch( $request );
$this->assertSame( 200, $response->get_status() );
$names = wp_list_pluck( $response->get_data(), 'name' );
$this->assertNotEmpty( $names, 'Expected at least one ability in the test namespace.' );
foreach ( $names as $name ) {
$this->assertStringStartsWith( 'test/', $name );
}
}
public function test_filter_by_nonexistent_namespace(): void {
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
$request->set_param( 'namespace', 'nonexistent' );
$response = $this->server->dispatch( $request );
$this->assertSame( 200, $response->get_status() );
$this->assertEmpty( $response->get_data() );
}
public function test_filter_by_namespace_still_respects_show_in_rest(): void {
// 'test/not-show-in-rest' is registered in the fixture with show_in_rest => false.
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
$request->set_param( 'namespace', 'test' );
$response = $this->server->dispatch( $request );
$names = wp_list_pluck( $response->get_data(), 'name' );
$this->assertNotContains( 'test/not-show-in-rest', $names );
} |
||
| } | ||
|
|
||
| $abilities = wp_get_abilities( $query_args ); | ||
|
|
||
| $page = $request['page']; | ||
| $per_page = $request['per_page']; | ||
| $offset = ( $page - 1 ) * $per_page; | ||
|
|
@@ -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', | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider dropping this early return so the full pipeline always runs.
The Trac ticket frames the two new filter hooks as the mechanism that lets plugins enforce universal rules — "A security plugin cannot enforce capability checks, the MCP adapter cannot gate visibility, and core cannot apply default scoping" — and concludes "Ecosystem hooks fire last at each level, so plugins always get the final say." The early bail prevents
wp_get_abilities_matchandwp_get_abilities_resultfrom firing on the most common call path (wp_get_abilities()with no args), which is the path security/visibility plugins most need to participate in.The perf cost of removing the bail is one foreach over an in-memory array — negligible next to anything else
wp_get_abilities()callers do, and the filter is brand new so there is no BC concern with hooks firing more often.For callers who genuinely want raw, unfiltered registry data,
WP_Abilities_Registry::get_all_registered()is the right escape hatch (and is already@see'd in the docblock). Worth strengthening that pointer alongside this change.Would also add a couple of tests asserting both filters fire when
wp_get_abilities()is called with no args, so the contract is pinned.