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}
615