Abilities API: Introduce server-side registry and REST API endpoints

Feature proposal at https://make.wordpress.org/ai/2025/07/17/abilities-api/.
Project developed in https://github.com/WordPress/abilities-api.

Introduces a new Abilities API that allows WordPress plugins and themes to register and execute custom abilities with built-in permission checking, input/output validation via JSON Schema, and REST API integration.

## Public Functions

### Ability Management
- `wp_register_ability( string $name, array $args ): ?WP_Ability` - Registers a new ability (must be called on `wp_abilities_api_init` hook)
- `wp_unregister_ability( string $name ): ?WP_Ability` - Unregisters an ability
- `wp_has_ability( string $name ): bool` - Checks if an ability is registered
- `wp_get_ability( string $name ): ?WP_Ability` - Retrieves a registered ability
- `wp_get_abilities(): array` - Retrieves all registered abilities

### Ability Category Management
- `wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category` - Registers an ability category (must be called on `wp_abilities_api_categories_init` hook)
- `wp_unregister_ability_category( string $slug ): ?WP_Ability_Category` - Unregisters an ability category
- `wp_has_ability_category( string $slug ): bool` - Checks if an ability category is registered
- `wp_get_ability_category( string $slug ): ?WP_Ability_Category` - Retrieves a registered ability category
- `wp_get_ability_categories(): array` - Retrieves all registered ability categories

## Public Classes

- `WP_Ability` - Encapsulates ability properties and methods (execute, check_permission, validate_input, etc.)
- `WP_Ability_Category` - Encapsulates ability category properties
- `WP_Abilities_Registry` - Manages ability registration and lookup (private, accessed via functions)
- `WP_Ability_Categories_Registry` - Manages ability category registration (private, accessed via functions)
- `WP_REST_Abilities_V1_List_Controller` - REST controller for listing abilities
- `WP_REST_Abilities_V1_Run_Controller` - REST controller for executing abilities

## REST API Endpoints

### Namespace: `wp-abilities/v1`

#### List Abilities
- `GET /wp-abilities/v1/abilities` - Retrieve all registered abilities
  - Query parameters: `page`, `per_page`, `category`

#### Get Single Ability
- `GET /wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)` - Retrieve a specific ability by name

#### Execute Ability
- `GET|POST|DELETE /wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)/run` - Execute an ability
  - Supports multiple HTTP methods based on ability annotations
  - Validates input against ability's input schema
  - Validates output against ability's output schema
  - Performs permission checks via ability's permission callback

## Hooks

### Actions
- `wp_abilities_api_categories_init` - Fired when ability categories registry is initialized (register categories here)
- `wp_abilities_api_init` - Fired when abilities registry is initialized (register abilities here)
- `wp_before_execute_ability` - Fired before an ability gets executed, after input validation and permissions check

- `wp_after_execute_ability` - Fires immediately after an ability finished executing

### Filters
- `wp_register_ability_category_args` - Filters ability category arguments before registration
- `wp_register_ability_args` - Filters ability arguments before registration

Developed in https://github.com/WordPress/wordpress-develop/pull/9410.

Props gziolo, jorbin, justlevine, westonruter, jason_the_adams, flixos90, karmatosed, timothyblynjacobs.
Fixes #64098.



Built from https://develop.svn.wordpress.org/trunk@61032


git-svn-id: http://core.svn.wordpress.org/trunk@60368 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
gziolo
2025-10-21 13:52:27 +00:00
parent c4e375fc4e
commit d3fe16afc4
10 changed files with 2267 additions and 1 deletions
+260
View File
@@ -0,0 +1,260 @@
<?php
/**
* Abilities API
*
* Defines functions for managing abilities in WordPress.
*
* @package WordPress
* @subpackage Abilities_API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Registers a new ability using Abilities API.
*
* Note: Should only be used on the {@see 'wp_abilities_api_init'} hook.
*
* @since 6.9.0
*
* @see WP_Abilities_Registry::register()
*
* @param string $name The name of the ability. The name must be a string containing a namespace
* prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
* alphanumeric characters, dashes and the forward slash.
* @param array<string, mixed> $args {
* An associative array of arguments for the ability.
*
* @type string $label The human-readable label for the ability.
* @type string $description A detailed description of what the ability does.
* @type string $category The ability category slug this ability belongs to.
* @type callable $execute_callback A callback function to execute when the ability is invoked.
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
* Optional. Additional metadata for the ability.
*
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
* }
* @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
* }
* @return WP_Ability|null An instance of registered ability on success, null on failure.
*/
function wp_register_ability( string $name, array $args ): ?WP_Ability {
if ( ! did_action( 'wp_abilities_api_init' ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: 1: abilities_api_init, 2: string value of the ability name. */
esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ),
'<code>abilities_api_init</code>',
'<code>' . esc_html( $name ) . '</code>'
),
'6.9.0'
);
return null;
}
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return null;
}
return $registry->register( $name, $args );
}
/**
* Unregisters an ability from the Abilities API.
*
* @since 6.9.0
*
* @see WP_Abilities_Registry::unregister()
*
* @param string $name The name of the registered ability, with its namespace.
* @return WP_Ability|null The unregistered ability instance on success, null on failure.
*/
function wp_unregister_ability( string $name ): ?WP_Ability {
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return null;
}
return $registry->unregister( $name );
}
/**
* Checks if an ability is registered.
*
* @since 6.9.0
*
* @see WP_Abilities_Registry::is_registered()
*
* @param string $name The name of the registered ability, with its namespace.
* @return bool True if the ability is registered, false otherwise.
*/
function wp_has_ability( string $name ): bool {
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return false;
}
return $registry->is_registered( $name );
}
/**
* Retrieves a registered ability using Abilities API.
*
* @since 6.9.0
*
* @see WP_Abilities_Registry::get_registered()
*
* @param string $name The name of the registered ability, with its namespace.
* @return WP_Ability|null The registered ability instance, or null if it is not registered.
*/
function wp_get_ability( string $name ): ?WP_Ability {
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return null;
}
return $registry->get_registered( $name );
}
/**
* Retrieves all registered abilities using Abilities API.
*
* @since 6.9.0
*
* @see WP_Abilities_Registry::get_all_registered()
*
* @return WP_Ability[] The array of registered abilities.
*/
function wp_get_abilities(): array {
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return array();
}
return $registry->get_all_registered();
}
/**
* Registers a new ability category.
*
* @since 6.9.0
*
* @see WP_Ability_Categories_Registry::register()
*
* @param string $slug The unique slug for the ability category. Must contain only lowercase
* alphanumeric characters and dashes.
* @param array<string, mixed> $args {
* An associative array of arguments for the ability category.
*
* @type string $label The human-readable label for the ability category.
* @type string $description A description of the ability category.
* @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
* }
* @return WP_Ability_Category|null The registered ability category instance on success, null on failure.
*/
function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category {
if ( ! did_action( 'wp_abilities_api_categories_init' ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: abilities_api_categories_init, 2: ability category slug. */
__( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ),
'<code>wp_abilities_api_categories_init</code>',
'<code>' . esc_html( $slug ) . '</code>'
),
'6.9.0'
);
return null;
}
$registry = WP_Ability_Categories_Registry::get_instance();
if ( null === $registry ) {
return null;
}
return $registry->register( $slug, $args );
}
/**
* Unregisters an ability category.
*
* @since 6.9.0
*
* @see WP_Ability_Categories_Registry::unregister()
*
* @param string $slug The slug of the registered ability category.
* @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure.
*/
function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category {
$registry = WP_Ability_Categories_Registry::get_instance();
if ( null === $registry ) {
return null;
}
return $registry->unregister( $slug );
}
/**
* Checks if an ability category is registered.
*
* @since 6.9.0
*
* @see WP_Ability_Categories_Registry::is_registered()
*
* @param string $slug The slug of the ability category.
* @return bool True if the ability category is registered, false otherwise.
*/
function wp_has_ability_category( string $slug ): bool {
$registry = WP_Ability_Categories_Registry::get_instance();
if ( null === $registry ) {
return false;
}
return $registry->is_registered( $slug );
}
/**
* Retrieves a registered ability category.
*
* @since 6.9.0
*
* @see WP_Ability_Categories_Registry::get_registered()
*
* @param string $slug The slug of the registered ability category.
* @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered.
*/
function wp_get_ability_category( string $slug ): ?WP_Ability_Category {
$registry = WP_Ability_Categories_Registry::get_instance();
if ( null === $registry ) {
return null;
}
return $registry->get_registered( $slug );
}
/**
* Retrieves all registered ability categories.
*
* @since 6.9.0
*
* @see WP_Ability_Categories_Registry::get_all_registered()
*
* @return WP_Ability_Category[] The array of registered ability categories.
*/
function wp_get_ability_categories(): array {
$registry = WP_Ability_Categories_Registry::get_instance();
if ( null === $registry ) {
return array();
}
return $registry->get_all_registered();
}
@@ -0,0 +1,320 @@
<?php
/**
* Abilities API
*
* Defines WP_Abilities_Registry class.
*
* @package WordPress
* @subpackage Abilities API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Manages the registration and lookup of abilities.
*
* @since 6.9.0
* @access private
*/
final class WP_Abilities_Registry {
/**
* The singleton instance of the registry.
*
* @since 6.9.0
* @var self|null
*/
private static $instance = null;
/**
* Holds the registered abilities.
*
* @since 6.9.0
* @var WP_Ability[]
*/
private $registered_abilities = array();
/**
* Registers a new ability.
*
* Do not use this method directly. Instead, use the `wp_register_ability()` function.
*
* @since 6.9.0
*
* @see wp_register_ability()
*
* @param string $name The name of the ability. The name must be a string containing a namespace
* prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
* alphanumeric characters, dashes and the forward slash.
* @param array<string, mixed> $args {
* An associative array of arguments for the ability.
*
* @type string $label The human-readable label for the ability.
* @type string $description A detailed description of what the ability does.
* @type string $category The ability category slug this ability belongs to.
* @type callable $execute_callback A callback function to execute when the ability is invoked.
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
* Optional. Additional metadata for the ability.
*
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
* }
* @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
* }
* @return WP_Ability|null The registered ability instance on success, null on failure.
*/
public function register( string $name, array $args ): ?WP_Ability {
if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) {
_doing_it_wrong(
__METHOD__,
__(
'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.'
),
'6.9.0'
);
return null;
}
if ( $this->is_registered( $name ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Ability name. */
sprintf( __( 'Ability "%s" is already registered.' ), esc_html( $name ) ),
'6.9.0'
);
return null;
}
/**
* Filters the ability arguments before they are validated and used to instantiate the ability.
*
* @since 6.9.0
*
* @param array<string, mixed> $args {
* An associative array of arguments for the ability.
*
* @type string $label The human-readable label for the ability.
* @type string $description A detailed description of what the ability does.
* @type string $category The ability category slug this ability belongs to.
* @type callable $execute_callback A callback function to execute when the ability is invoked.
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
* Optional. Additional metadata for the ability.
*
* @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
* }
* @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
* }
* @param string $name The name of the ability, with its namespace.
*/
$args = apply_filters( 'wp_register_ability_args', $args, $name );
// Validate ability category exists if provided (will be validated as required in WP_Ability).
if ( isset( $args['category'] ) ) {
$category_registry = WP_Ability_Categories_Registry::get_instance();
if ( ! $category_registry->is_registered( $args['category'] ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %1$s: ability category slug, %2$s: ability name */
__( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ),
esc_html( $args['category'] ),
esc_html( $name )
),
'6.9.0'
);
return null;
}
}
// The class is only used to instantiate the ability, and is not a property of the ability itself.
if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) {
_doing_it_wrong(
__METHOD__,
__( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ),
'6.9.0'
);
return null;
}
/** @var class-string<WP_Ability> */
$ability_class = $args['ability_class'] ?? WP_Ability::class;
unset( $args['ability_class'] );
try {
// WP_Ability::prepare_properties() will throw an exception if the properties are invalid.
$ability = new $ability_class( $name, $args );
} catch ( InvalidArgumentException $e ) {
_doing_it_wrong(
__METHOD__,
$e->getMessage(),
'6.9.0'
);
return null;
}
$this->registered_abilities[ $name ] = $ability;
return $ability;
}
/**
* Unregisters an ability.
*
* Do not use this method directly. Instead, use the `wp_unregister_ability()` function.
*
* @since 6.9.0
*
* @see wp_unregister_ability()
*
* @param string $name The name of the registered ability, with its namespace.
* @return WP_Ability|null The unregistered ability instance on success, null on failure.
*/
public function unregister( string $name ): ?WP_Ability {
if ( ! $this->is_registered( $name ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Ability name. */
sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ),
'6.9.0'
);
return null;
}
$unregistered_ability = $this->registered_abilities[ $name ];
unset( $this->registered_abilities[ $name ] );
return $unregistered_ability;
}
/**
* Retrieves the list of all registered abilities.
*
* Do not use this method directly. Instead, use the `wp_get_abilities()` function.
*
* @since 6.9.0
*
* @see wp_get_abilities()
*
* @return WP_Ability[] The array of registered abilities.
*/
public function get_all_registered(): array {
return $this->registered_abilities;
}
/**
* Checks if an ability is registered.
*
* Do not use this method directly. Instead, use the `wp_has_ability()` function.
*
* @since 6.9.0
*
* @see wp_has_ability()
*
* @param string $name The name of the registered ability, with its namespace.
* @return bool True if the ability is registered, false otherwise.
*/
public function is_registered( string $name ): bool {
return isset( $this->registered_abilities[ $name ] );
}
/**
* Retrieves a registered ability.
*
* Do not use this method directly. Instead, use the `wp_get_ability()` function.
*
* @since 6.9.0
*
* @see wp_get_ability()
*
* @param string $name The name of the registered ability, with its namespace.
* @return ?WP_Ability The registered ability instance, or null if it is not registered.
*/
public function get_registered( string $name ): ?WP_Ability {
if ( ! $this->is_registered( $name ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Ability name. */
sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ),
'6.9.0'
);
return null;
}
return $this->registered_abilities[ $name ];
}
/**
* Utility method to retrieve the main instance of the registry class.
*
* The instance will be created if it does not exist yet.
*
* @since 6.9.0
*
* @return WP_Abilities_Registry|null The main registry instance, or null when `init` action has not fired.
*/
public static function get_instance(): ?self {
if ( ! did_action( 'init' ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
__( 'Ability API should not be initialized before the <code>init</code> action has fired' )
),
'6.9.0'
);
return null;
}
if ( null === self::$instance ) {
self::$instance = new self();
// Ensure ability category registry is initialized first to allow categories to be registered
// before abilities that depend on them.
WP_Ability_Categories_Registry::get_instance();
/**
* Fires when preparing abilities registry.
*
* Abilities should be created and register their hooks on this action rather
* than another action to ensure they're only loaded when needed.
*
* @since 6.9.0
*
* @param WP_Abilities_Registry $instance Abilities registry object.
*/
do_action( 'wp_abilities_api_init', self::$instance );
}
return self::$instance;
}
/**
* Wakeup magic method.
*
* @since 6.9.0
* @throws LogicException If the registry object is unserialized.
* This is a security hardening measure to prevent unserialization of the registry.
*/
public function __wakeup(): void {
throw new LogicException( __CLASS__ . ' should never be unserialized.' );
}
/**
* Sleep magic method.
*
* @since 6.9.0
* @throws LogicException If the registry object is serialized.
* This is a security hardening measure to prevent serialization of the registry.
*/
public function __sleep(): array {
throw new LogicException( __CLASS__ . ' should never be serialized' );
}
}
@@ -0,0 +1,254 @@
<?php
/**
* Abilities API
*
* Defines WP_Ability_Categories_Registry class.
*
* @package WordPress
* @subpackage Abilities API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Manages the registration and lookup of ability categories.
*
* @since 6.9.0
* @access private
*/
final class WP_Ability_Categories_Registry {
/**
* The singleton instance of the registry.
*
* @since 6.9.0
* @var self|null
*/
private static $instance = null;
/**
* Holds the registered ability categories.
*
* @since 6.9.0
* @var WP_Ability_Category[]
*/
private $registered_categories = array();
/**
* Registers a new ability category.
*
* Do not use this method directly. Instead, use the `wp_register_ability_category()` function.
*
* @since 6.9.0
*
* @see wp_register_ability_category()
*
* @param string $slug The unique slug for the ability category. Must contain only lowercase
* alphanumeric characters and dashes.
* @param array<string, mixed> $args {
* An associative array of arguments for the ability category.
*
* @type string $label The human-readable label for the ability category.
* @type string $description A description of the ability category.
* @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
* }
* @return WP_Ability_Category|null The registered ability category instance on success, null on failure.
*/
public function register( string $slug, array $args ): ?WP_Ability_Category {
if ( $this->is_registered( $slug ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Ability category slug. */
sprintf( __( 'Ability category "%s" is already registered.' ), esc_html( $slug ) ),
'6.9.0'
);
return null;
}
if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
_doing_it_wrong(
__METHOD__,
__( 'Ability category slug must contain only lowercase alphanumeric characters and dashes.' ),
'6.9.0'
);
return null;
}
/**
* Filters the ability category arguments before they are validated and used to instantiate the ability category.
*
* @since 6.9.0
*
* @param array<string, mixed> $args {
* The arguments used to instantiate the ability category.
*
* @type string $label The human-readable label for the ability category.
* @type string $description A description of the ability category.
* @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
* }
* @param string $slug The slug of the ability category.
*/
$args = apply_filters( 'wp_register_ability_category_args', $args, $slug );
try {
// WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid.
$category = new WP_Ability_Category( $slug, $args );
} catch ( InvalidArgumentException $e ) {
_doing_it_wrong(
__METHOD__,
$e->getMessage(),
'6.9.0'
);
return null;
}
$this->registered_categories[ $slug ] = $category;
return $category;
}
/**
* Unregisters an ability category.
*
* Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function.
*
* @since 6.9.0
*
* @see wp_unregister_ability_category()
*
* @param string $slug The slug of the registered ability category.
* @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure.
*/
public function unregister( string $slug ): ?WP_Ability_Category {
if ( ! $this->is_registered( $slug ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Ability category slug. */
sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ),
'6.9.0'
);
return null;
}
$unregistered_category = $this->registered_categories[ $slug ];
unset( $this->registered_categories[ $slug ] );
return $unregistered_category;
}
/**
* Retrieves the list of all registered ability categories.
*
* Do not use this method directly. Instead, use the `wp_get_ability_categories()` function.
*
* @since 6.9.0
*
* @see wp_get_ability_categories()
*
* @return array<string, WP_Ability_Category> The array of registered ability categories.
*/
public function get_all_registered(): array {
return $this->registered_categories;
}
/**
* Checks if an ability category is registered.
*
* Do not use this method directly. Instead, use the `wp_has_ability_category()` function.
*
* @since 6.9.0
*
* @see wp_has_ability_category()
*
* @param string $slug The slug of the ability category.
* @return bool True if the ability category is registered, false otherwise.
*/
public function is_registered( string $slug ): bool {
return isset( $this->registered_categories[ $slug ] );
}
/**
* Retrieves a registered ability category.
*
* Do not use this method directly. Instead, use the `wp_get_ability_category()` function.
*
* @since 6.9.0
*
* @see wp_get_ability_category()
*
* @param string $slug The slug of the registered ability category.
* @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered.
*/
public function get_registered( string $slug ): ?WP_Ability_Category {
if ( ! $this->is_registered( $slug ) ) {
_doing_it_wrong(
__METHOD__,
/* translators: %s: Ability category slug. */
sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ),
'6.9.0'
);
return null;
}
return $this->registered_categories[ $slug ];
}
/**
* Utility method to retrieve the main instance of the registry class.
*
* The instance will be created if it does not exist yet.
*
* @since 6.9.0
*
* @return WP_Ability_Categories_Registry|null The main registry instance, or null when `init` action has not fired.
*/
public static function get_instance(): ?self {
if ( ! did_action( 'init' ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
__( 'Ability API should not be initialized before the <code>init</code> action has fired' )
),
'6.9.0'
);
return null;
}
if ( null === self::$instance ) {
self::$instance = new self();
/**
* Fires when preparing ability categories registry.
*
* Ability categories should be registered on this action to ensure they're available when needed.
*
* @since 6.9.0
*
* @param WP_Ability_Categories_Registry $instance Ability categories registry object.
*/
do_action( 'wp_abilities_api_categories_init', self::$instance );
}
return self::$instance;
}
/**
* Wakeup magic method.
*
* @since 6.9.0
* @throws LogicException If the registry object is unserialized.
* This is a security hardening measure to prevent unserialization of the registry.
*/
public function __wakeup(): void {
throw new LogicException( __CLASS__ . ' should never be unserialized.' );
}
/**
* Sleep magic method.
*
* @since 6.9.0
* @throws LogicException If the registry object is serialized.
* This is a security hardening measure to prevent serialization of the registry.
*/
public function __sleep(): array {
throw new LogicException( __CLASS__ . ' should never be serialized' );
}
}
@@ -0,0 +1,216 @@
<?php
/**
* Abilities API
*
* Defines WP_Ability_Category class.
*
* @package WordPress
* @subpackage Abilities API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Encapsulates the properties and methods related to a specific ability category.
*
* @since 6.9.0
*
* @see WP_Ability_Categories_Registry
*/
final class WP_Ability_Category {
/**
* The unique slug for the ability category.
*
* @since 6.9.0
* @var string
*/
protected $slug;
/**
* The human-readable ability category label.
*
* @since 6.9.0
* @var string
*/
protected $label;
/**
* The detailed ability category description.
*
* @since 6.9.0
* @var string
*/
protected $description;
/**
* The optional ability category metadata.
*
* @since 6.9.0
* @var array<string, mixed>
*/
protected $meta = array();
/**
* Constructor.
*
* Do not use this constructor directly. Instead, use the `wp_register_ability_category()` function.
*
* @access private
*
* @since 6.9.0
*
* @see wp_register_ability_category()
*
* @param string $slug The unique slug for the ability category.
* @param array<string, mixed> $args {
* An associative array of arguments for the ability category.
*
* @type string $label The human-readable label for the ability category.
* @type string $description A description of the ability category.
* @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
* }
*/
public function __construct( string $slug, array $args ) {
if ( empty( $slug ) ) {
throw new InvalidArgumentException(
esc_html__( 'The ability category slug cannot be empty.' )
);
}
$this->slug = $slug;
$properties = $this->prepare_properties( $args );
foreach ( $properties as $property_name => $property_value ) {
if ( ! property_exists( $this, $property_name ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: Property name. */
__( 'Property "%1$s" is not a valid property for ability category "%2$s". Please check the %3$s class for allowed properties.' ),
'<code>' . esc_html( $property_name ) . '</code>',
'<code>' . esc_html( $this->slug ) . '</code>',
'<code>' . __CLASS__ . '</code>'
),
'6.9.0'
);
continue;
}
$this->$property_name = $property_value;
}
}
/**
* Prepares and validates the properties used to instantiate the ability category.
*
* @since 6.9.0
*
* @param array<string, mixed> $args $args {
* An associative array of arguments used to instantiate the ability category class.
*
* @type string $label The human-readable label for the ability category.
* @type string $description A description of the ability category.
* @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
* }
* @return array<string, mixed> $args {
* An associative array with validated and prepared ability category properties.
*
* @type string $label The human-readable label for the ability category.
* @type string $description A description of the ability category.
* @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
* }
* @throws InvalidArgumentException if an argument is invalid.
*/
protected function prepare_properties( array $args ): array {
// Required args must be present and of the correct type.
if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
throw new InvalidArgumentException(
__( 'The ability category properties must contain a `label` string.' )
);
}
if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) {
throw new InvalidArgumentException(
__( 'The ability category properties must contain a `description` string.' )
);
}
// Optional args only need to be of the correct type if they are present.
if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
throw new InvalidArgumentException(
__( 'The ability category properties should provide a valid `meta` array.' )
);
}
return $args;
}
/**
* Retrieves the slug of the ability category.
*
* @since 6.9.0
*
* @return string The ability category slug.
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Retrieves the human-readable label for the ability category.
*
* @since 6.9.0
*
* @return string The human-readable ability category label.
*/
public function get_label(): string {
return $this->label;
}
/**
* Retrieves the detailed description for the ability category.
*
* @since 6.9.0
*
* @return string The detailed description for the ability category.
*/
public function get_description(): string {
return $this->description;
}
/**
* Retrieves the metadata for the ability category.
*
* @since 6.9.0
*
* @return array<string,mixed> The metadata for the ability category.
*/
public function get_meta(): array {
return $this->meta;
}
/**
* Wakeup magic method.
*
* @since 6.9.0
* @throws LogicException If the ability category object is unserialized.
* This is a security hardening measure to prevent unserialization of the ability category.
*/
public function __wakeup(): void {
throw new LogicException( __CLASS__ . ' should never be unserialized.' );
}
/**
* Sleep magic method.
*
* @since 6.9.0
* @throws LogicException If the ability category object is serialized.
* This is a security hardening measure to prevent serialization of the ability category.
*/
public function __sleep(): array {
throw new LogicException( __CLASS__ . ' should never be serialized' );
}
}
@@ -0,0 +1,617 @@
<?php
/**
* Abilities API
*
* Defines WP_Ability class.
*
* @package WordPress
* @subpackage Abilities API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Encapsulates the properties and methods related to a specific ability in the registry.
*
* @since 6.9.0
*
* @see WP_Abilities_Registry
*/
class WP_Ability {
/**
* The default value for the `show_in_rest` meta.
*
* @since 6.9.0
* @var bool
*/
protected const DEFAULT_SHOW_IN_REST = false;
/**
* The default ability annotations.
* They are not guaranteed to provide a faithful description of ability behavior.
*
* @since 6.9.0
* @var array<string, (null|bool)>
*/
protected static $default_annotations = array(
// If true, the ability does not modify its environment.
'readonly' => null,
/*
* If true, the ability may perform destructive updates to its environment.
* If false, the ability performs only additive updates.
*/
'destructive' => null,
/*
* If true, calling the ability repeatedly with the same arguments will have no additional effect
* on its environment.
*/
'idempotent' => null,
);
/**
* The name of the ability, with its namespace.
* Example: `my-plugin/my-ability`.
*
* @since 6.9.0
* @var string
*/
protected $name;
/**
* The human-readable ability label.
*
* @since 6.9.0
* @var string
*/
protected $label;
/**
* The detailed ability description.
*
* @since 6.9.0
* @var string
*/
protected $description;
/**
* The ability category.
*
* @since 6.9.0
* @var string
*/
protected $category;
/**
* The optional ability input schema.
*
* @since 6.9.0
* @var array<string, mixed>
*/
protected $input_schema = array();
/**
* The optional ability output schema.
*
* @since 6.9.0
* @var array<string, mixed>
*/
protected $output_schema = array();
/**
* The ability execute callback.
*
* @since 6.9.0
* @var callable( mixed $input= ): (mixed|WP_Error)
*/
protected $execute_callback;
/**
* The optional ability permission callback.
*
* @since 6.9.0
* @var callable( mixed $input= ): (bool|WP_Error)
*/
protected $permission_callback;
/**
* The optional ability metadata.
*
* @since 6.9.0
* @var array<string, mixed>
*/
protected $meta;
/**
* Constructor.
*
* Do not use this constructor directly. Instead, use the `wp_register_ability()` function.
*
* @access private
*
* @since 6.9.0
*
* @see wp_register_ability()
*
* @param string $name The name of the ability, with its namespace.
* @param array<string, mixed> $args {
* An associative array of arguments for the ability.
*
* @type string $label The human-readable label for the ability.
* @type string $description A detailed description of what the ability does.
* @type string $category The ability category slug this ability belongs to.
* @type callable $execute_callback A callback function to execute when the ability is invoked.
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
* Optional. Additional metadata for the ability.
*
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
* }
* }
*/
public function __construct( string $name, array $args ) {
$this->name = $name;
$properties = $this->prepare_properties( $args );
foreach ( $properties as $property_name => $property_value ) {
if ( ! property_exists( $this, $property_name ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: Property name. */
__( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ),
'<code>' . esc_html( $property_name ) . '</code>',
'<code>' . esc_html( $this->name ) . '</code>',
'<code>' . self::class . '</code>'
),
'6.9.0'
);
continue;
}
$this->$property_name = $property_value;
}
}
/**
* Prepares and validates the properties used to instantiate the ability.
*
* Errors are thrown as exceptions instead of WP_Errors to allow for simpler handling and overloading. They are then
* caught and converted to a WP_Error when by WP_Abilities_Registry::register().
*
* @since 6.9.0
*
* @see WP_Abilities_Registry::register()
*
* @param array<string, mixed> $args {
* An associative array of arguments used to instantiate the ability class.
*
* @type string $label The human-readable label for the ability.
* @type string $description A detailed description of what the ability does.
* @type string $category The ability category slug this ability belongs to.
* @type callable $execute_callback A callback function to execute when the ability is invoked.
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
* Optional. Additional metadata for the ability.
*
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
* }
* }
* @return array<string, mixed> {
* An associative array of arguments with validated and prepared properties for the ability class.
*
* @type string $label The human-readable label for the ability.
* @type string $description A detailed description of what the ability does.
* @type string $category The ability category slug this ability belongs to.
* @type callable $execute_callback A callback function to execute when the ability is invoked.
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
* Additional metadata for the ability.
*
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
* @type bool $show_in_rest Whether to expose this ability in the REST API. Default false.
* }
* }
* @throws InvalidArgumentException if an argument is invalid.
*/
protected function prepare_properties( array $args ): array {
// Required args must be present and of the correct type.
if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties must contain a `label` string.' )
);
}
if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties must contain a `description` string.' )
);
}
if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties must contain a `category` string.' )
);
}
if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties must contain a valid `execute_callback` function.' )
);
}
if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties must provide a valid `permission_callback` function.' )
);
}
// Optional args only need to be of the correct type if they are present.
if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties should provide a valid `input_schema` definition.' )
);
}
if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties should provide a valid `output_schema` definition.' )
);
}
if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties should provide a valid `meta` array.' )
);
}
if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) {
throw new InvalidArgumentException(
__( 'The ability meta should provide a valid `annotations` array.' )
);
}
if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) {
throw new InvalidArgumentException(
__( 'The ability meta should provide a valid `show_in_rest` boolean.' )
);
}
// Set defaults for optional meta.
$args['meta'] = wp_parse_args(
$args['meta'] ?? array(),
array(
'annotations' => static::$default_annotations,
'show_in_rest' => self::DEFAULT_SHOW_IN_REST,
)
);
$args['meta']['annotations'] = wp_parse_args(
$args['meta']['annotations'],
static::$default_annotations
);
return $args;
}
/**
* Retrieves the name of the ability, with its namespace.
* Example: `my-plugin/my-ability`.
*
* @since 6.9.0
*
* @return string The ability name, with its namespace.
*/
public function get_name(): string {
return $this->name;
}
/**
* Retrieves the human-readable label for the ability.
*
* @since 6.9.0
*
* @return string The human-readable ability label.
*/
public function get_label(): string {
return $this->label;
}
/**
* Retrieves the detailed description for the ability.
*
* @since 6.9.0
*
* @return string The detailed description for the ability.
*/
public function get_description(): string {
return $this->description;
}
/**
* Retrieves the ability category for the ability.
*
* @since 6.9.0
*
* @return string The ability category for the ability.
*/
public function get_category(): string {
return $this->category;
}
/**
* Retrieves the input schema for the ability.
*
* @since 6.9.0
*
* @return array<string, mixed> The input schema for the ability.
*/
public function get_input_schema(): array {
return $this->input_schema;
}
/**
* Retrieves the output schema for the ability.
*
* @since 6.9.0
*
* @return array<string, mixed> The output schema for the ability.
*/
public function get_output_schema(): array {
return $this->output_schema;
}
/**
* Retrieves the metadata for the ability.
*
* @since 6.9.0
*
* @return array<string, mixed> The metadata for the ability.
*/
public function get_meta(): array {
return $this->meta;
}
/**
* Retrieves a specific metadata item for the ability.
*
* @since 6.9.0
*
* @param string $key The metadata key to retrieve.
* @param mixed $default_value Optional. The default value to return if the metadata item is not found. Default `null`.
* @return mixed The value of the metadata item, or the default value if not found.
*/
public function get_meta_item( string $key, $default_value = null ) {
return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value;
}
/**
* Validates input data against the input schema.
*
* @since 6.9.0
*
* @param mixed $input Optional. The input data to validate. Default `null`.
* @return true|WP_Error Returns true if valid or the WP_Error object if validation fails.
*/
public function validate_input( $input = null ) {
$input_schema = $this->get_input_schema();
if ( empty( $input_schema ) ) {
if ( null === $input ) {
return true;
}
return new WP_Error(
'ability_missing_input_schema',
sprintf(
/* translators: %s ability name. */
__( 'Ability "%s" does not define an input schema required to validate the provided input.' ),
$this->name
)
);
}
$valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' );
if ( is_wp_error( $valid_input ) ) {
return new WP_Error(
'ability_invalid_input',
sprintf(
/* translators: %1$s ability name, %2$s error message. */
__( 'Ability "%1$s" has invalid input. Reason: %2$s' ),
$this->name,
$valid_input->get_error_message()
)
);
}
return true;
}
/**
* Invokes a callable, ensuring the input is passed through only if the input schema is defined.
*
* @since 6.9.0
*
* @param callable $callback The callable to invoke.
* @param mixed $input Optional. The input data for the ability. Default `null`.
* @return mixed The result of the callable execution.
*/
protected function invoke_callback( callable $callback, $input = null ) {
$args = array();
if ( ! empty( $this->get_input_schema() ) ) {
$args[] = $input;
}
return $callback( ...$args );
}
/**
* Checks whether the ability has the necessary permissions.
*
* Please note that input is not automatically validated against the input schema.
* Use `validate_input()` method to validate input before calling this method if needed.
*
* @since 6.9.0
*
* @see validate_input()
*
* @param mixed $input Optional. The valid input data for permission checking. Default `null`.
* @return bool|WP_Error Whether the ability has the necessary permission.
*/
public function check_permissions( $input = null ) {
return $this->invoke_callback( $this->permission_callback, $input );
}
/**
* Executes the ability callback.
*
* @since 6.9.0
*
* @param mixed $input Optional. The input data for the ability. Default `null`.
* @return mixed|WP_Error The result of the ability execution, or WP_Error on failure.
*/
protected function do_execute( $input = null ) {
if ( ! is_callable( $this->execute_callback ) ) {
return new WP_Error(
'ability_invalid_execute_callback',
/* translators: %s ability name. */
sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name )
);
}
return $this->invoke_callback( $this->execute_callback, $input );
}
/**
* Validates output data against the output schema.
*
* @since 6.9.0
*
* @param mixed $output The output data to validate.
* @return true|WP_Error Returns true if valid, or a WP_Error object if validation fails.
*/
protected function validate_output( $output ) {
$output_schema = $this->get_output_schema();
if ( empty( $output_schema ) ) {
return true;
}
$valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' );
if ( is_wp_error( $valid_output ) ) {
return new WP_Error(
'ability_invalid_output',
sprintf(
/* translators: %1$s ability name, %2$s error message. */
__( 'Ability "%1$s" has invalid output. Reason: %2$s' ),
$this->name,
$valid_output->get_error_message()
)
);
}
return true;
}
/**
* Executes the ability after input validation and running a permission check.
* Before returning the return value, it also validates the output.
*
* @since 6.9.0
*
* @param mixed $input Optional. The input data for the ability. Default `null`.
* @return mixed|WP_Error The result of the ability execution, or WP_Error on failure.
*/
public function execute( $input = null ) {
$is_valid = $this->validate_input( $input );
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
}
$has_permissions = $this->check_permissions( $input );
if ( true !== $has_permissions ) {
if ( is_wp_error( $has_permissions ) ) {
// Don't leak the permission check error to someone without the correct perms.
_doing_it_wrong(
__METHOD__,
esc_html( $has_permissions->get_error_message() ),
'6.9.0'
);
}
return new WP_Error(
'ability_invalid_permissions',
/* translators: %s ability name. */
sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name )
);
}
/**
* Fires before an ability gets executed, after input validation and permissions check.
*
* @since 6.9.0
*
* @param string $ability_name The name of the ability.
* @param mixed $input The input data for the ability.
*/
do_action( 'wp_before_execute_ability', $this->name, $input );
$result = $this->do_execute( $input );
if ( is_wp_error( $result ) ) {
return $result;
}
$is_valid = $this->validate_output( $result );
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
}
/**
* Fires immediately after an ability finished executing.
*
* @since 6.9.0
*
* @param string $ability_name The name of the ability.
* @param mixed $input The input data for the ability.
* @param mixed $result The result of the ability execution.
*/
do_action( 'wp_after_execute_ability', $this->name, $input, $result );
return $result;
}
/**
* Wakeup magic method.
*
* @since 6.9.0
* @throws LogicException If the ability object is unserialized.
* This is a security hardening measure to prevent unserialization of the ability.
*/
public function __wakeup(): void {
throw new LogicException( __CLASS__ . ' should never be unserialized.' );
}
/**
* Sleep magic method.
*
* @since 6.9.0
* @throws LogicException If the ability object is serialized.
* This is a security hardening measure to prevent serialization of the ability.
*/
public function __sleep(): array {
throw new LogicException( __CLASS__ . ' should never be serialized' );
}
}
+6
View File
@@ -483,6 +483,12 @@ function create_initial_rest_routes() {
// Font Collections.
$font_collections_controller = new WP_REST_Font_Collections_Controller();
$font_collections_controller->register_routes();
// Abilities.
$abilities_run_controller = new WP_REST_Abilities_V1_Run_Controller();
$abilities_run_controller->register_routes();
$abilities_list_controller = new WP_REST_Abilities_V1_List_Controller();
$abilities_list_controller->register_routes();
}
/**
@@ -0,0 +1,343 @@
<?php
/**
* REST API list controller for Abilities API.
*
* @package WordPress
* @subpackage Abilities_API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Core controller used to access abilities via the REST API.
*
* @since 6.9.0
*
* @see WP_REST_Controller
*/
class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller {
/**
* REST API namespace.
*
* @since 6.9.0
* @var string
*/
protected $namespace = 'wp-abilities/v1';
/**
* REST API base route.
*
* @since 6.9.0
* @var string
*/
protected $rest_base = 'abilities';
/**
* Registers the routes for abilities.
*
* @since 6.9.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+)',
array(
'args' => array(
'name' => array(
'description' => __( 'Unique identifier for the ability.' ),
'type' => 'string',
'pattern' => '^[a-zA-Z0-9\-\/]+$',
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Retrieves all abilities.
*
* @since 6.9.0
*
* @param WP_REST_Request $request Full details about the request.
* @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' );
}
);
// 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 );
}
$page = $request['page'];
$per_page = $request['per_page'];
$offset = ( $page - 1 ) * $per_page;
$total_abilities = count( $abilities );
$max_pages = ceil( $total_abilities / $per_page );
if ( $request->get_method() === 'HEAD' ) {
$response = new WP_REST_Response( array() );
} else {
$abilities = array_slice( $abilities, $offset, $per_page );
$data = array();
foreach ( $abilities as $ability ) {
$item = $this->prepare_item_for_response( $ability, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
$response = rest_ensure_response( $data );
}
$response->header( 'X-WP-Total', (string) $total_abilities );
$response->header( 'X-WP-TotalPages', (string) $max_pages );
$query_params = $request->get_query_params();
$base = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
if ( $page > 1 ) {
$prev_page = $page - 1;
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $page < $max_pages ) {
$next_page = $page + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
/**
* Retrieves a specific ability.
*
* @since 6.9.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item( $request ) {
$ability = wp_get_ability( $request['name'] );
if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) {
return new WP_Error(
'rest_ability_not_found',
__( 'Ability not found.' ),
array( 'status' => 404 )
);
}
$data = $this->prepare_item_for_response( $ability, $request );
return rest_ensure_response( $data );
}
/**
* Checks if a given request has access to read ability items.
*
* @since 6.9.0
*
* @param WP_REST_Request $request Full details about the request.
* @return bool True if the request has read access.
*/
public function get_items_permissions_check( $request ) {
return current_user_can( 'read' );
}
/**
* Checks if a given request has access to read an ability item.
*
* @since 6.9.0
*
* @param WP_REST_Request $request Full details about the request.
* @return bool True if the request has read access.
*/
public function get_item_permissions_check( $request ) {
return current_user_can( 'read' );
}
/**
* Prepares an ability for response.
*
* @since 6.9.0
*
* @param WP_Ability $ability The ability object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $ability, $request ) {
$data = array(
'name' => $ability->get_name(),
'label' => $ability->get_label(),
'description' => $ability->get_description(),
'category' => $ability->get_category(),
'input_schema' => $ability->get_input_schema(),
'output_schema' => $ability->get_output_schema(),
'meta' => $ability->get_meta(),
);
$context = $request['context'] ?? 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
$response = rest_ensure_response( $data );
$fields = $this->get_fields_for_response( $request );
if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
$links = array(
'self' => array(
'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $ability->get_name() ) ),
),
'collection' => array(
'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
),
);
$links['wp:action-run'] = array(
'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ),
);
$response->add_links( $links );
}
return $response;
}
/**
* Retrieves the ability's schema, conforming to JSON Schema.
*
* @since 6.9.0
*
* @return array<string, mixed> Item schema data.
*/
public function get_item_schema(): array {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'ability',
'type' => 'object',
'properties' => array(
'name' => array(
'description' => __( 'Unique identifier for the ability.' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'label' => array(
'description' => __( 'Display label for the ability.' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'description' => array(
'description' => __( 'Description of the ability.' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'category' => array(
'description' => __( 'Ability category this ability belongs to.' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
'input_schema' => array(
'description' => __( 'JSON Schema for the ability input.' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'output_schema' => array(
'description' => __( 'JSON Schema for the ability output.' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'meta' => array(
'description' => __( 'Meta information about the ability.' ),
'type' => 'object',
'properties' => array(
'annotations' => array(
'description' => __( 'Annotations for the ability.' ),
'type' => array( 'boolean', 'null' ),
'default' => null,
),
),
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Retrieves the query params for collections.
*
* @since 6.9.0
*
* @return array<string, mixed> Collection parameters.
*/
public function get_collection_params(): array {
return array(
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
'page' => array(
'description' => __( 'Current page of the collection.' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'per_page' => array(
'description' => __( 'Maximum number of items to be returned in result set.' ),
'type' => 'integer',
'default' => 50,
'minimum' => 1,
'maximum' => 100,
),
'category' => array(
'description' => __( 'Limit results to abilities in specific ability category.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
);
}
}
@@ -0,0 +1,243 @@
<?php
/**
* REST API run controller for Abilities API.
*
* @package WordPress
* @subpackage Abilities_API
* @since 6.9.0
*/
declare( strict_types = 1 );
/**
* Core controller used to execute abilities via the REST API.
*
* @since 6.9.0
*
* @see WP_REST_Controller
*/
class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
/**
* REST API namespace.
*
* @since 6.9.0
* @var string
*/
protected $namespace = 'wp-abilities/v1';
/**
* REST API base route.
*
* @since 6.9.0
* @var string
*/
protected $rest_base = 'abilities';
/**
* Registers the routes for ability execution.
*
* @since 6.9.0
*
* @see register_rest_route()
*/
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+?)/run',
array(
'args' => array(
'name' => array(
'description' => __( 'Unique identifier for the ability.' ),
'type' => 'string',
'pattern' => '^[a-zA-Z0-9\-\/]+$',
),
),
// TODO: We register ALLMETHODS because at route registration time, we don't know which abilities
// exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress
// load order - routes are registered early, before plugins have registered their abilities.
// This approach works but could be improved with lazy route registration or a different
// architecture that allows type-specific routes after abilities are registered.
// This was the same issue that we ended up seeing with the Feature API.
array(
'methods' => WP_REST_Server::ALLMETHODS,
'callback' => array( $this, 'execute_ability' ),
'permission_callback' => array( $this, 'check_ability_permissions' ),
'args' => $this->get_run_args(),
),
'schema' => array( $this, 'get_run_schema' ),
)
);
}
/**
* Executes an ability.
*
* @since 6.9.0
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function execute_ability( $request ) {
$ability = wp_get_ability( $request['name'] );
if ( ! $ability ) {
return new WP_Error(
'rest_ability_not_found',
__( 'Ability not found.' ),
array( 'status' => 404 )
);
}
$input = $this->get_input_from_request( $request );
$result = $ability->execute( $input );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( $result );
}
/**
* Validates if the HTTP method matches the expected method for the ability based on its annotations.
*
* @since 6.9.0
*
* @param string $request_method The HTTP method of the request.
* @param array<string, (null|bool)> $annotations The ability annotations.
* @return true|WP_Error True on success, or WP_Error object on failure.
*/
public function validate_request_method( string $request_method, array $annotations ) {
$expected_method = 'POST';
if ( ! empty( $annotations['readonly'] ) ) {
$expected_method = 'GET';
} elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) {
$expected_method = 'DELETE';
}
if ( $expected_method === $request_method ) {
return true;
}
$error_message = __( 'Abilities that perform updates require POST method.' );
if ( 'GET' === $expected_method ) {
$error_message = __( 'Read-only abilities require GET method.' );
} elseif ( 'DELETE' === $expected_method ) {
$error_message = __( 'Abilities that perform destructive actions require DELETE method.' );
}
return new WP_Error(
'rest_ability_invalid_method',
$error_message,
array( 'status' => 405 )
);
}
/**
* Checks if a given request has permission to execute a specific ability.
*
* @since 6.9.0
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if the request has execution permission, WP_Error object otherwise.
*/
public function check_ability_permissions( $request ) {
$ability = wp_get_ability( $request['name'] );
if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) {
return new WP_Error(
'rest_ability_not_found',
__( 'Ability not found.' ),
array( 'status' => 404 )
);
}
$is_valid = $this->validate_request_method(
$request->get_method(),
$ability->get_meta_item( 'annotations' )
);
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
}
$input = $this->get_input_from_request( $request );
$is_valid = $ability->validate_input( $input );
if ( is_wp_error( $is_valid ) ) {
$is_valid->add_data( array( 'status' => 400 ) );
return $is_valid;
}
$result = $ability->check_permissions( $input );
if ( is_wp_error( $result ) ) {
$result->add_data( array( 'status' => rest_authorization_required_code() ) );
return $result;
}
if ( ! $result ) {
return new WP_Error(
'rest_ability_cannot_execute',
__( 'Sorry, you are not allowed to execute this ability.' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Extracts input parameters from the request.
*
* @since 6.9.0
*
* @param WP_REST_Request $request The request object.
* @return mixed|null The input parameters.
*/
private function get_input_from_request( $request ) {
if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) {
// For GET and DELETE requests, look for 'input' query parameter.
$query_params = $request->get_query_params();
return $query_params['input'] ?? null;
}
// For POST requests, look for 'input' in JSON body.
$json_params = $request->get_json_params();
return $json_params['input'] ?? null;
}
/**
* Retrieves the arguments for ability execution endpoint.
*
* @since 6.9.0
*
* @return array<string, mixed> Arguments for the run endpoint.
*/
public function get_run_args(): array {
return array(
'input' => array(
'description' => __( 'Input parameters for the ability execution.' ),
'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ),
'default' => null,
),
);
}
/**
* Retrieves the schema for ability execution endpoint.
*
* @since 6.9.0
*
* @return array<string, mixed> Schema for the run endpoint.
*/
public function get_run_schema(): array {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'ability-execution',
'type' => 'object',
'properties' => array(
'result' => array(
'description' => __( 'The result of the ability execution.' ),
'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ),
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
$wp_version = '6.9-alpha-61031';
$wp_version = '6.9-alpha-61032';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
+7
View File
@@ -285,6 +285,11 @@ require ABSPATH . WPINC . '/nav-menu-template.php';
require ABSPATH . WPINC . '/nav-menu.php';
require ABSPATH . WPINC . '/admin-bar.php';
require ABSPATH . WPINC . '/class-wp-application-passwords.php';
require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php';
require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php';
require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php';
require ABSPATH . WPINC . '/abilities-api.php';
require ABSPATH . WPINC . '/rest-api.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';
@@ -331,6 +336,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-navigation-fallback
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php';
require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php';
require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php';
require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php';