Make WordPress Core

source: trunk/src/wp-includes/abilities-api.php

Last change on this file was 61130, checked in by jorgefilipecosta, 6 weeks ago

Abilities API: Use stricter doing_action check when registering.

Applies feedback provided that doing_action would be better check on this situation to avoid developers facing future registration timing issues.

Developed in #10452.

Props gziolo, jorgefilipecosta, flixos90, mukesh27, jason_the_adams.
See #64098.

  • Property svn:eol-style set to native
File size: 23.8 KB
Line 
1<?php
2/**
3 * Abilities API: core functions for registering and managing abilities.
4 *
5 * The Abilities API provides a unified, extensible framework for registering
6 * and executing discrete capabilities within WordPress. An "ability" is a
7 * self-contained unit of functionality with defined inputs, outputs, permissions,
8 * and execution logic.
9 *
10 * ## Overview
11 *
12 * The Abilities API enables developers to:
13 *
14 *  - Register custom abilities with standardized interfaces.
15 *  - Define permission checks and execution callbacks.
16 *  - Organize abilities into logical categories.
17 *  - Validate inputs and outputs using JSON Schema.
18 *  - Expose abilities through the REST API.
19 *
20 * ## Working with Abilities
21 *
22 * Abilities must be registered on the `wp_abilities_api_init` action hook.
23 * Attempting to register an ability outside of this hook will fail and
24 * trigger a `_doing_it_wrong()` notice.
25
26 * Example:
27 *
28 *     function my_plugin_register_abilities(): void {
29 *         wp_register_ability(
30 *             'my-plugin/export-users',
31 *             array(
32 *                 'label'               => __( 'Export Users', 'my-plugin' ),
33 *                 'description'         => __( 'Exports user data to CSV format.', 'my-plugin' ),
34 *                 'category'            => 'data-export',
35 *                 'execute_callback'    => 'my_plugin_export_users',
36 *                 'permission_callback' => function(): bool {
37 *                     return current_user_can( 'export' );
38 *                 },
39 *                 'input_schema'        => array(
40 *                     'type'        => 'string',
41 *                     'enum'        => array( 'subscriber', 'contributor', 'author', 'editor', 'administrator' ),
42 *                     'description' => __( 'Limits the export to users with this role.', 'my-plugin' ),
43 *                     'required'    => false,
44 *                 ),
45 *                 'output_schema'       => array(
46 *                     'type'        => 'string',
47 *                     'description' => __( 'User data in CSV format.', 'my-plugin' ),
48 *                     'required'    => true,
49 *                 ),
50 *                 'meta'                => array(
51 *                     'show_in_rest' => true,
52 *                 ),
53 *             )
54 *         );
55 *     }
56 *     add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );
57 *
58 * Once registered, abilities can be checked, retrieved, and managed:
59 *
60 *     // Checks if an ability is registered, and prints its label.
61 *     if ( wp_has_ability( 'my-plugin/export-users' ) ) {
62 *         $ability = wp_get_ability( 'my-plugin/export-users' );
63 *
64 *         echo $ability->get_label();
65 *     }
66 *
67 *     // Gets all registered abilities.
68 *     $all_abilities = wp_get_abilities();
69 *
70 *     // Unregisters when no longer needed.
71 *     wp_unregister_ability( 'my-plugin/export-users' );
72 *
73 * ## Best Practices
74 *
75 *  - Always register abilities on the `wp_abilities_api_init` hook.
76 *  - Use namespaced ability names to prevent conflicts.
77 *  - Implement robust permission checks in permission callbacks.
78 *  - Provide an `input_schema` to ensure data integrity and document expected inputs.
79 *  - Define an `output_schema` to describe return values and validate responses.
80 *  - Return `WP_Error` objects for failures rather than throwing exceptions.
81 *  - Use internationalization functions for all user-facing strings.
82 *
83 * @package WordPress
84 * @subpackage Abilities_API
85 * @since 6.9.0
86 */
87
88declare( strict_types = 1 );
89
90/**
91 * Registers a new ability using the Abilities API. It requires three steps:
92 *
93 *  1. Hook into the `wp_abilities_api_init` action.
94 *  2. Call `wp_register_ability()` with a namespaced name and configuration.
95 *  3. Provide execute and permission callbacks.
96 *
97 * Example:
98 *
99 *     function my_plugin_register_abilities(): void {
100 *         wp_register_ability(
101 *             'my-plugin/analyze-text',
102 *             array(
103 *                 'label'               => __( 'Analyze Text', 'my-plugin' ),
104 *                 'description'         => __( 'Performs sentiment analysis on provided text.', 'my-plugin' ),
105 *                 'category'            => 'text-processing',
106 *                 'input_schema'        => array(
107 *                     'type'        => 'string',
108 *                     'description' => __( 'The text to be analyzed.', 'my-plugin' ),
109 *                     'minLength'   => 10,
110 *                     'required'    => true,
111 *                 ),
112 *                 'output_schema'       => array(
113 *                     'type'        => 'string',
114 *                     'enum'        => array( 'positive', 'negative', 'neutral' ),
115 *                     'description' => __( 'The sentiment result: positive, negative, or neutral.', 'my-plugin' ),
116 *                     'required'    => true,
117 *                 ),
118 *                 'execute_callback'    => 'my_plugin_analyze_text',
119 *                 'permission_callback' => 'my_plugin_can_analyze_text',
120 *                 'meta'                => array(
121 *                     'annotations'   => array(
122 *                         'readonly' => true,
123 *                     ),
124 *                     'show_in_rest' => true,
125 *                 ),
126 *             )
127 *         );
128 *     }
129 *     add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );
130 *
131 * ### Naming Conventions
132 *
133 * Ability names must follow these rules:
134 *
135 *  - Include a namespace prefix (e.g., `my-plugin/my-ability`).
136 *  - Use only lowercase alphanumeric characters, dashes, and forward slashes.
137 *  - Use descriptive, action-oriented names (e.g., `process-payment`, `generate-report`).
138 *
139 * ### Categories
140 *
141 * Abilities must be organized into categories. Ability categories provide better
142 * discoverability and must be registered before the abilities that reference them:
143 *
144 *     function my_plugin_register_categories(): void {
145 *         wp_register_ability_category(
146 *             'text-processing',
147 *             array(
148 *                 'label'       => __( 'Text Processing', 'my-plugin' ),
149 *                 'description' => __( 'Abilities for analyzing and transforming text.', 'my-plugin' ),
150 *             )
151 *         );
152 *     }
153 *     add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_categories' );
154 *
155 * ### Input and Output Schemas
156 *
157 * Schemas define the expected structure, type, and constraints for ability inputs
158 * and outputs using JSON Schema syntax. They serve two critical purposes: automatic
159 * validation of data passed to and returned from abilities, and self-documenting
160 * API contracts for developers.
161 *
162 * WordPress implements a validator based on a subset of the JSON Schema Version 4
163 * specification (https://json-schema.org/specification-links.html#draft-4).
164 * For details on supported JSON Schema properties and syntax, see the
165 * related WordPress REST API Schema documentation:
166 * https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/#json-schema-basics
167 *
168 * Defining schemas is mandatory when there is a value to pass or return.
169 * They ensure data integrity, improve developer experience, and enable
170 * better documentation:
171 *
172 *     'input_schema' => array(
173 *         'type'        => 'string',
174 *         'description' => __( 'The text to be analyzed.', 'my-plugin' ),
175 *         'minLength'   => 10,
176 *         'required'    => true,
177 *     ),
178 *     'output_schema'       => array(
179 *         'type'        => 'string',
180 *         'enum'        => array( 'positive', 'negative', 'neutral' ),
181 *         'description' => __( 'The sentiment result: positive, negative, or neutral.', 'my-plugin' ),
182 *         'required'    => true,
183 *     ),
184 *
185 * ### Callbacks
186 *
187 * #### Execute Callback
188 *
189 * The execute callback performs the ability's core functionality. It receives
190 * optional input data and returns either a result or `WP_Error` on failure.
191 *
192 *     function my_plugin_analyze_text( string $input ): string|WP_Error {
193 *         $score = My_Plugin::perform_sentiment_analysis( $input );
194 *         if ( is_wp_error( $score ) ) {
195 *             return $score;
196 *         }
197 *         return My_Plugin::interpret_sentiment_score( $score );
198 *     }
199 *
200 * #### Permission Callback
201 *
202 * The permission callback determines whether the ability can be executed.
203 * It receives the same input as the execute callback and must return a
204 * boolean or `WP_Error`. Common use cases include checking user capabilities,
205 * validating API keys, or verifying system state:
206 *
207 *     function my_plugin_can_analyze_text( string $input ): bool|WP_Error {
208 *         return current_user_can( 'edit_posts' );
209 *     }
210 *
211 * ### REST API Integration
212 *
213 * Abilities can be exposed through the REST API by setting `show_in_rest`
214 * to `true` in the meta configuration:
215 *
216 *     'meta' => array(
217 *         'show_in_rest' => true,
218 *     ),
219 *
220 * This allows abilities to be invoked via HTTP requests to the WordPress REST API.
221 *
222 * @since 6.9.0
223 *
224 * @see WP_Abilities_Registry::register()
225 * @see wp_register_ability_category()
226 * @see wp_unregister_ability()
227 *
228 * @param string               $name The name of the ability. Must be a namespaced string containing
229 *                                   a prefix, e.g., `my-plugin/my-ability`. Can only contain lowercase
230 *                                   alphanumeric characters, dashes, and forward slashes.
231 * @param array<string, mixed> $args {
232 *     An associative array of arguments for configuring the ability.
233 *
234 *     @type string               $label               Required. The human-readable label for the ability.
235 *     @type string               $description         Required. A detailed description of what the ability does
236 *                                                     and when it should be used.
237 *     @type string               $category            Required. The ability category slug this ability belongs to.
238 *                                                     The ability category must be registered via `wp_register_ability_category()`
239 *                                                     before registering the ability.
240 *     @type callable             $execute_callback    Required. A callback function to execute when the ability is invoked.
241 *                                                     Receives optional mixed input data and must return either a result
242 *                                                     value (any type) or a `WP_Error` object on failure.
243 *     @type callable             $permission_callback Required. A callback function to check permissions before execution.
244 *                                                     Receives optional mixed input data (same as `execute_callback`) and
245 *                                                     must return `true`/`false` for simple checks, or `WP_Error` for
246 *                                                     detailed error responses.
247 *     @type array<string, mixed> $input_schema        Optional. JSON Schema definition for validating the ability's input.
248 *                                                     Must be a valid JSON Schema object defining the structure and
249 *                                                     constraints for input data. Used for automatic validation and
250 *                                                     API documentation.
251 *     @type array<string, mixed> $output_schema       Optional. JSON Schema definition for the ability's output.
252 *                                                     Describes the structure of successful return values from
253 *                                                     `execute_callback`. Used for documentation and validation.
254 *     @type array<string, mixed> $meta                {
255 *         Optional. Additional metadata for the ability.
256 *
257 *         @type array<string, bool|null> $annotations  {
258 *             Optional. Semantic annotations describing the ability's behavioral characteristics.
259 *             These annotations are hints for tooling and documentation.
260 *
261 *             @type bool|null $readonly    Optional. If true, the ability does not modify its environment.
262 *             @type bool|null $destructive Optional. If true, the ability may perform destructive updates to its environment.
263 *                                          If false, the ability performs only additive updates.
264 *             @type bool|null $idempotent  Optional. If true, calling the ability repeatedly with the same arguments
265 *                                          will have no additional effect on its environment.
266 *         }
267 *         @type bool                     $show_in_rest Optional. Whether to expose this ability in the REST API.
268 *                                                      When true, the ability can be invoked via HTTP requests.
269 *                                                      Default false.
270 *     }
271 *     @type string               $ability_class       Optional. Fully-qualified custom class name to instantiate
272 *                                                     instead of the default `WP_Ability` class. The custom class
273 *                                                     must extend `WP_Ability`. Useful for advanced customization
274 *                                                     of ability behavior.
275 * }
276 * @return WP_Ability|null The registered ability instance on success, `null` on failure.
277 */
278function wp_register_ability( string $name, array $args ): ?WP_Ability {
279        if ( ! doing_action( 'wp_abilities_api_init' ) ) {
280                _doing_it_wrong(
281                        __FUNCTION__,
282                        sprintf(
283                                /* translators: 1: wp_abilities_api_init, 2: string value of the ability name. */
284                                __( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ),
285                                '<code>wp_abilities_api_init</code>',
286                                '<code>' . esc_html( $name ) . '</code>'
287                        ),
288                        '6.9.0'
289                );
290                return null;
291        }
292
293        $registry = WP_Abilities_Registry::get_instance();
294        if ( null === $registry ) {
295                return null;
296        }
297
298        return $registry->register( $name, $args );
299}
300
301/**
302 * Unregisters an ability from the Abilities API.
303 *
304 * Removes a previously registered ability from the global registry. Use this to
305 * disable abilities provided by other plugins or when an ability is no longer needed.
306 *
307 * Can be called at any time after the ability has been registered.
308 *
309 * Example:
310 *
311 *     if ( wp_has_ability( 'other-plugin/some-ability' ) ) {
312 *         wp_unregister_ability( 'other-plugin/some-ability' );
313 *     }
314 *
315 * @since 6.9.0
316 *
317 * @see WP_Abilities_Registry::unregister()
318 * @see wp_register_ability()
319 *
320 * @param string $name The name of the ability to unregister, including namespace prefix
321 *                     (e.g., 'my-plugin/my-ability').
322 * @return WP_Ability|null The unregistered ability instance on success, `null` on failure.
323 */
324function wp_unregister_ability( string $name ): ?WP_Ability {
325        $registry = WP_Abilities_Registry::get_instance();
326        if ( null === $registry ) {
327                return null;
328        }
329
330        return $registry->unregister( $name );
331}
332
333/**
334 * Checks if an ability is registered.
335 *
336 * Use this for conditional logic and feature detection before attempting to
337 * retrieve or use an ability.
338 *
339 * Example:
340 *
341 *     // Displays different UI based on available abilities.
342 *     if ( wp_has_ability( 'premium-plugin/advanced-export' ) ) {
343 *         echo 'Export with Premium Features';
344 *     } else {
345 *         echo 'Basic Export';
346 *     }
347 *
348 * @since 6.9.0
349 *
350 * @see WP_Abilities_Registry::is_registered()
351 * @see wp_get_ability()
352 *
353 * @param string $name The name of the ability to check, including namespace prefix
354 *                     (e.g., 'my-plugin/my-ability').
355 * @return bool `true` if the ability is registered, `false` otherwise.
356 */
357function wp_has_ability( string $name ): bool {
358        $registry = WP_Abilities_Registry::get_instance();
359        if ( null === $registry ) {
360                return false;
361        }
362
363        return $registry->is_registered( $name );
364}
365
366/**
367 * Retrieves a registered ability.
368 *
369 * Returns the ability instance for inspection or use. The instance provides access
370 * to the ability's configuration, metadata, and execution methods.
371 *
372 * Example:
373 *
374 *     // Prints information about a registered ability.
375 *     $ability = wp_get_ability( 'my-plugin/export-data' );
376 *     if ( $ability ) {
377 *         echo $ability->get_label() . ': ' . $ability->get_description();
378 *     }
379 *
380 * @since 6.9.0
381 *
382 * @see WP_Abilities_Registry::get_registered()
383 * @see wp_has_ability()
384 *
385 * @param string $name The name of the ability, including namespace prefix
386 *                     (e.g., 'my-plugin/my-ability').
387 * @return WP_Ability|null The registered ability instance, or `null` if not registered.
388 */
389function wp_get_ability( string $name ): ?WP_Ability {
390        $registry = WP_Abilities_Registry::get_instance();
391        if ( null === $registry ) {
392                return null;
393        }
394
395        return $registry->get_registered( $name );
396}
397
398/**
399 * Retrieves all registered abilities.
400 *
401 * Returns an array of all ability instances currently registered in the system.
402 * Use this for discovery, debugging, or building administrative interfaces.
403 *
404 * Example:
405 *
406 *     // Prints information about all available abilities.
407 *     $abilities = wp_get_abilities();
408 *     foreach ( $abilities as $ability ) {
409 *         echo $ability->get_label() . ': ' . $ability->get_description() . "\n";
410 *     }
411 *
412 * @since 6.9.0
413 *
414 * @see WP_Abilities_Registry::get_all_registered()
415 *
416 * @return WP_Ability[] An array of registered WP_Ability instances. Returns an empty
417 *                     array if no abilities are registered or if the registry is unavailable.
418 */
419function wp_get_abilities(): array {
420        $registry = WP_Abilities_Registry::get_instance();
421        if ( null === $registry ) {
422                return array();
423        }
424
425        return $registry->get_all_registered();
426}
427
428/**
429 * Registers a new ability category.
430 *
431 * Ability categories provide a way to organize and group related abilities for better
432 * discoverability and management. Ability categories must be registered before abilities
433 * that reference them.
434 *
435 * Ability categories must be registered on the `wp_abilities_api_categories_init` action hook.
436 *
437 * Example:
438 *
439 *     function my_plugin_register_categories() {
440 *         wp_register_ability_category(
441 *             'content-management',
442 *             array(
443 *                 'label'       => __( 'Content Management', 'my-plugin' ),
444 *                 'description' => __( 'Abilities for managing and organizing content.', 'my-plugin' ),
445 *             )
446 *         );
447 *     }
448 *     add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_categories' );
449 *
450 * @since 6.9.0
451 *
452 * @see WP_Ability_Categories_Registry::register()
453 * @see wp_register_ability()
454 * @see wp_unregister_ability_category()
455 *
456 * @param string               $slug The unique slug for the ability category. Must contain only lowercase
457 *                                   alphanumeric characters and dashes (e.g., 'data-export').
458 * @param array<string, mixed> $args {
459 *     An associative array of arguments for the ability category.
460 *
461 *     @type string               $label       Required. The human-readable label for the ability category.
462 *     @type string               $description Required. A description of what abilities in this category do.
463 *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
464 * }
465 * @return WP_Ability_Category|null The registered ability category instance on success, `null` on failure.
466 */
467function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category {
468        if ( ! doing_action( 'wp_abilities_api_categories_init' ) ) {
469                _doing_it_wrong(
470                        __FUNCTION__,
471                        sprintf(
472                                /* translators: 1: wp_abilities_api_categories_init, 2: ability category slug. */
473                                __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ),
474                                '<code>wp_abilities_api_categories_init</code>',
475                                '<code>' . esc_html( $slug ) . '</code>'
476                        ),
477                        '6.9.0'
478                );
479                return null;
480        }
481
482        $registry = WP_Ability_Categories_Registry::get_instance();
483        if ( null === $registry ) {
484                return null;
485        }
486
487        return $registry->register( $slug, $args );
488}
489
490/**
491 * Unregisters an ability category.
492 *
493 * Removes a previously registered ability category from the global registry. Use this to
494 * disable ability categories that are no longer needed.
495 *
496 * Can be called at any time after the ability category has been registered.
497 *
498 * Example:
499 *
500 *     if ( wp_has_ability_category( 'deprecated-category' ) ) {
501 *         wp_unregister_ability_category( 'deprecated-category' );
502 *     }
503 *
504 * @since 6.9.0
505 *
506 * @see WP_Ability_Categories_Registry::unregister()
507 * @see wp_register_ability_category()
508 *
509 * @param string $slug The slug of the ability category to unregister.
510 * @return WP_Ability_Category|null The unregistered ability category instance on success, `null` on failure.
511 */
512function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category {
513        $registry = WP_Ability_Categories_Registry::get_instance();
514        if ( null === $registry ) {
515                return null;
516        }
517
518        return $registry->unregister( $slug );
519}
520
521/**
522 * Checks if an ability category is registered.
523 *
524 * Use this for conditional logic and feature detection before attempting to
525 * retrieve or use an ability category.
526 *
527 * Example:
528 *
529 *     // Displays different UI based on available ability categories.
530 *     if ( wp_has_ability_category( 'premium-features' ) ) {
531 *         echo 'Premium Features Available';
532 *     } else {
533 *         echo 'Standard Features';
534 *     }
535 *
536 * @since 6.9.0
537 *
538 * @see WP_Ability_Categories_Registry::is_registered()
539 * @see wp_get_ability_category()
540 *
541 * @param string $slug The slug of the ability category to check.
542 * @return bool `true` if the ability category is registered, `false` otherwise.
543 */
544function wp_has_ability_category( string $slug ): bool {
545        $registry = WP_Ability_Categories_Registry::get_instance();
546        if ( null === $registry ) {
547                return false;
548        }
549
550        return $registry->is_registered( $slug );
551}
552
553/**
554 * Retrieves a registered ability category.
555 *
556 * Returns the ability category instance for inspection or use. The instance provides access
557 * to the ability category's configuration and metadata.
558 *
559 * Example:
560 *
561 *     // Prints information about a registered ability category.
562 *     $ability_category = wp_get_ability_category( 'content-management' );
563 *     if ( $ability_category ) {
564 *         echo $ability_category->get_label() . ': ' . $ability_category->get_description();
565 *     }
566 *
567 * @since 6.9.0
568 *
569 * @see WP_Ability_Categories_Registry::get_registered()
570 * @see wp_has_ability_category()
571 * @see wp_get_ability_categories()
572 *
573 * @param string $slug The slug of the ability category.
574 * @return WP_Ability_Category|null The ability category instance, or `null` if not registered.
575 */
576function wp_get_ability_category( string $slug ): ?WP_Ability_Category {
577        $registry = WP_Ability_Categories_Registry::get_instance();
578        if ( null === $registry ) {
579                return null;
580        }
581
582        return $registry->get_registered( $slug );
583}
584
585/**
586 * Retrieves all registered ability categories.
587 *
588 * Returns an array of all ability category instances currently registered in the system.
589 * Use this for discovery, debugging, or building administrative interfaces.
590 *
591 * Example:
592 *
593 *     // Prints information about all available ability categories.
594 *     $ability_categories = wp_get_ability_categories();
595 *     foreach ( $ability_categories as $ability_category ) {
596 *         echo $ability_category->get_label() . ': ' . $ability_category->get_description() . "\n";
597 *     }
598 *
599 * @since 6.9.0
600 *
601 * @see WP_Ability_Categories_Registry::get_all_registered()
602 * @see wp_get_ability_category()
603 *
604 * @return WP_Ability_Category[] An array of registered ability category instances. Returns an empty array
605 *                               if no ability categories are registered or if the registry is unavailable.
606 */
607function wp_get_ability_categories(): array {
608        $registry = WP_Ability_Categories_Registry::get_instance();
609        if ( null === $registry ) {
610                return array();
611        }
612
613        return $registry->get_all_registered();
614}
Note: See TracBrowser for help on using the repository browser.