diff --git a/includes/abilities-api.php b/includes/abilities-api.php index f898ac00..4c892fb8 100644 --- a/includes/abilities-api.php +++ b/includes/abilities-api.php @@ -25,7 +25,7 @@ * alphanumeric characters, dashes and the forward slash. * @param array $args An associative array of arguments for the ability. This should include * `label`, `description`, `input_schema`, `output_schema`, `execute_callback`, - * `permission_callback`, `meta`, and `ability_class`. + * `permission_callback`, `annotations`, `meta`, and `ability_class`. * @return ?\WP_Ability An instance of registered ability on success, null on failure. * * @phpstan-param array{ @@ -35,6 +35,7 @@ * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, + * annotations?: array, * meta?: array, * ability_class?: class-string<\WP_Ability>, * ... diff --git a/includes/abilities-api/class-wp-abilities-registry.php b/includes/abilities-api/class-wp-abilities-registry.php index 3acf8a14..e7a970d8 100644 --- a/includes/abilities-api/class-wp-abilities-registry.php +++ b/includes/abilities-api/class-wp-abilities-registry.php @@ -57,6 +57,7 @@ final class WP_Abilities_Registry { * permission_callback?: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, + * annotations?: array, * meta?: array, * ability_class?: class-string<\WP_Ability>, * ... diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 78ac5c3e..42240931 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -19,6 +19,29 @@ * @see WP_Abilities_Registry */ class WP_Ability { + /** + * The default ability annotations. + * They are not guaranteed to provide a faithful description of ability behavior. + * + * @since n.e.x.t + * @var array + */ + protected static $default_annotations = array( + // Instructions on how to use the ability. + 'instructions' => '', + // If true, the ability does not modify its environment. + 'readonly' => false, + /* + * If true, the ability may perform destructive updates to its environment. + * If false, the ability performs only additive updates. + */ + 'destructive' => true, + /* + * If true, calling the ability repeatedly with the same arguments will have no additional effect + * on its environment. + */ + 'idempotent' => false, + ); /** * The name of the ability, with its namespace. @@ -77,6 +100,14 @@ class WP_Ability { */ protected $permission_callback; + /** + * The ability annotations. + * + * @since n.e.x.t + * @var array + */ + protected $annotations = array(); + /** * The optional ability metadata. * @@ -99,7 +130,7 @@ class WP_Ability { * @param string $name The name of the ability, with its namespace. * @param array $args An associative array of arguments for the ability. This should * include `label`, `description`, `input_schema`, `output_schema`, - * `execute_callback`, `permission_callback`, and `meta`. + * `execute_callback`, `permission_callback`, `annotations`, and `meta`. */ public function __construct( string $name, array $args ) { $this->name = $name; @@ -147,6 +178,7 @@ public function __construct( string $name, array $args ) { * permission_callback: callable( mixed $input= ): (bool|\WP_Error), * input_schema?: array, * output_schema?: array, + * annotations?: array, * meta?: array, * ..., * } $args @@ -190,12 +222,24 @@ protected function prepare_properties( array $args ): array { ); } + if ( isset( $args['annotations'] ) && ! is_array( $args['annotations'] ) ) { + throw new \InvalidArgumentException( + esc_html__( 'The ability properties should provide a valid `annotations` array.' ) + ); + } + if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { throw new \InvalidArgumentException( esc_html__( 'The ability properties should provide a valid `meta` array.' ) ); } + // Set defaults for optional args. + $args['annotations'] = wp_parse_args( + $args['annotations'] ?? array(), + static::$default_annotations + ); + return $args; } @@ -255,6 +299,17 @@ public function get_output_schema(): array { return $this->output_schema; } + /** + * Retrieves the annotations for the ability. + * + * @since n.e.x.t + * + * @return array The annotations for the ability. + */ + public function get_annotations(): array { + return $this->annotations; + } + /** * Retrieves the metadata for the ability. * diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php index 3727c844..c996da50 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-list-controller.php @@ -191,6 +191,7 @@ public function prepare_item_for_response( $ability, $request ) { 'description' => $ability->get_description(), 'input_schema' => $ability->get_input_schema(), 'output_schema' => $ability->get_output_schema(), + 'annotations' => $ability->get_annotations(), 'meta' => $ability->get_meta(), ); @@ -264,6 +265,12 @@ public function get_item_schema(): array { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'annotations' => array( + 'description' => __( 'Annotations for the ability.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), 'meta' => array( 'description' => __( 'Meta information about the ability.' ), 'type' => 'object', diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index a54003a5..a54f3579 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -54,8 +54,8 @@ public function register_routes(): void { ), ), - // TODO: We register ALLMETHODS because at route registration time, we don't know - // which abilities exist or their types (resource vs tool). This is due to WordPress + // 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. @@ -90,23 +90,23 @@ public function run_ability_with_method_check( $request ) { ); } - // Check if the HTTP method matches the ability type. - $meta = $ability->get_meta(); - $type = isset( $meta['type'] ) ? $meta['type'] : 'tool'; - $method = $request->get_method(); + // Check if the HTTP method matches the ability annotations. + $annotations = $ability->get_annotations(); + $is_readonly = ! empty( $annotations['readonly'] ); + $method = $request->get_method(); - if ( 'resource' === $type && 'GET' !== $method ) { + if ( $is_readonly && 'GET' !== $method ) { return new \WP_Error( 'rest_ability_invalid_method', - __( 'Resource abilities require GET method.' ), + __( 'Read-only abilities require GET method.' ), array( 'status' => 405 ) ); } - if ( 'tool' === $type && 'POST' !== $method ) { + if ( ! $is_readonly && 'POST' !== $method ) { return new \WP_Error( 'rest_ability_invalid_method', - __( 'Tool abilities require POST method.' ), + __( 'Abilities that perform updates require POST method.' ), array( 'status' => 405 ) ); } diff --git a/packages/client/README.md b/packages/client/README.md index 90d1f5d6..6da54bca 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -28,13 +28,13 @@ const { getAbilities, getAbility, executeAbility } = wp.abilities; const abilities = await getAbilities(); // Get a specific ability -const ability = await getAbility('my-plugin/my-ability'); +const ability = await getAbility( 'my-plugin/my-ability' ); // Execute an ability -const result = await executeAbility('my-plugin/my-ability', { +const result = await executeAbility( 'my-plugin/my-ability', { param1: 'value1', param2: 'value2', -}); +} ); ``` ### Using with React and WordPress Data @@ -47,12 +47,12 @@ import { store as abilitiesStore } from '@wordpress/abilities'; function MyComponent() { const abilities = useSelect( - (select) => select(abilitiesStore).getAbilities(), + ( select ) => select( abilitiesStore ).getAbilities(), [] ); const specificAbility = useSelect( - (select) => select(abilitiesStore).getAbility('my-plugin/my-ability'), + ( select ) => select( abilitiesStore ).getAbility( 'my-plugin/my-ability' ), [] ); @@ -60,11 +60,11 @@ function MyComponent() {

All Abilities

    - {abilities.map((ability) => ( -
  • - {ability.label}: {ability.description} + { abilities.map( ( ability ) => ( +
  • + { ability.label }: { ability.description }
  • - ))} + ) ) }
); @@ -81,7 +81,7 @@ Returns all registered abilities. Automatically handles pagination to fetch all ```javascript const abilities = await getAbilities(); -console.log(`Found ${abilities.length} abilities`); +console.log( `Found ${ abilities.length } abilities` ); ``` #### `getAbility(name: string): Promise` @@ -89,30 +89,30 @@ console.log(`Found ${abilities.length} abilities`); Returns a specific ability by name, or null if not found. ```javascript -const ability = await getAbility('my-plugin/create-post'); -if (ability) { - console.log(`Found ability: ${ability.label}`); +const ability = await getAbility( 'my-plugin/create-post' ); +if ( ability ) { + console.log( `Found ability: ${ ability.label }` ); } ``` #### `executeAbility(name: string, input?: Record): Promise` -Executes an ability with optional input parameters. The HTTP method is automatically determined based on the ability's type: +Executes an ability with optional input parameters. The HTTP method is automatically determined based on the ability's annotations: -- `resource` type abilities use GET (read-only operations) -- `tool` type abilities use POST (write operations) +- `readonly` abilities use GET (read-only operations) +- regular abilities use POST (write operations) ```javascript -// Execute a resource ability (GET) -const data = await executeAbility('my-plugin/get-data', { +// Execute a read-only ability (GET) +const data = await executeAbility( 'my-plugin/get-data', { id: 123, -}); +} ); -// Execute a tool ability (POST) -const result = await executeAbility('my-plugin/create-item', { +// Execute a regular ability (POST) +const result = await executeAbility( 'my-plugin/create-item', { title: 'New Item', content: 'Item content', -}); +} ); ``` ### Store Selectors diff --git a/packages/client/src/__tests__/api.test.ts b/packages/client/src/__tests__/api.test.ts index 8f17e3f3..678d354f 100644 --- a/packages/client/src/__tests__/api.test.ts +++ b/packages/client/src/__tests__/api.test.ts @@ -259,12 +259,12 @@ describe( 'API functions', () => { ).rejects.toThrow( 'invalid input' ); } ); - it( 'should execute a resource-type ability via GET', async () => { + it( 'should execute a read-only ability via GET', async () => { const mockAbility: Ability = { - name: 'test/resource', - label: 'Resource Ability', - description: 'Test resource ability', - meta: { type: 'resource' }, + name: 'test/read-only', + label: 'Read-only Ability', + description: 'Test read-only ability.', + annotations: { readonly: true }, input_schema: { type: 'object', properties: { @@ -280,27 +280,27 @@ describe( 'API functions', () => { getAbility: mockGetAbility, } ); - const mockResponse = { data: 'resource data' }; + const mockResponse = { data: 'read-only data' }; ( apiFetch as unknown as jest.Mock ).mockResolvedValue( mockResponse ); const input = { id: '123', format: 'json' }; - const result = await executeAbility( 'test/resource', input ); + const result = await executeAbility( 'test/read-only', input ); expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/abilities/test/resource/run?input%5Bid%5D=123&input%5Bformat%5D=json', + path: '/wp/v2/abilities/test/read-only/run?input%5Bid%5D=123&input%5Bformat%5D=json', method: 'GET', } ); expect( result ).toEqual( mockResponse ); } ); - it( 'should execute a resource-type ability with empty input', async () => { + it( 'should execute a read-only ability with empty input', async () => { const mockAbility: Ability = { - name: 'test/resource', - label: 'Resource Ability', - description: 'Test resource ability', - meta: { type: 'resource' }, + name: 'test/read-only', + label: 'Read-only Ability', + description: 'Test read-only ability.', + annotations: { readonly: true }, input_schema: { type: 'object' }, output_schema: { type: 'object' }, }; @@ -310,15 +310,15 @@ describe( 'API functions', () => { getAbility: mockGetAbility, } ); - const mockResponse = { data: 'all resources' }; + const mockResponse = { data: 'read-only data' }; ( apiFetch as unknown as jest.Mock ).mockResolvedValue( mockResponse ); - const result = await executeAbility( 'test/resource', {} ); + const result = await executeAbility( 'test/read-only', {} ); expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/abilities/test/resource/run?', + path: '/wp/v2/abilities/test/read-only/run?', method: 'GET', } ); expect( result ).toEqual( mockResponse ); diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 55f869a1..7de90e9a 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -176,8 +176,7 @@ async function executeServerAbility( ability: Ability, input: AbilityInput ): Promise< AbilityOutput > { - const isResource = ability.meta?.type === 'resource'; - const method = isResource ? 'GET' : 'POST'; + const method = !! ability.annotations?.readonly ? 'GET' : 'POST'; let path = `/wp/v2/abilities/${ ability.name }/run`; const options: { diff --git a/packages/client/src/store/__tests__/reducer.test.ts b/packages/client/src/store/__tests__/reducer.test.ts index d10611a2..ccc8e0f1 100644 --- a/packages/client/src/store/__tests__/reducer.test.ts +++ b/packages/client/src/store/__tests__/reducer.test.ts @@ -118,46 +118,21 @@ describe( 'Store Reducer', () => { ).not.toHaveProperty( '_embedded' ); } ); - it( 'should filter out location property', () => { - const abilities = [ - { - name: 'test/ability', - label: 'Test Ability', - description: 'Test ability with location', - location: 'client', - }, - ]; - - const action = { - type: RECEIVE_ABILITIES, - abilities, - }; - - const state = reducer( - { abilitiesByName: defaultState }, - action - ); - - expect( - state.abilitiesByName[ 'test/ability' ] - ).not.toHaveProperty( 'location' ); - } ); - it( 'should preserve all valid ability properties', () => { const abilities = [ { name: 'test/ability', label: 'Test Ability', - description: 'Full test ability', + description: 'Full test ability.', input_schema: { type: 'object' }, output_schema: { type: 'object' }, - meta: { type: 'resource' as const }, + annotations: { readonly: true }, + meta: { category: 'test' }, callback: () => Promise.resolve( {} ), permissionCallback: () => true, // Extra properties that should be filtered out _links: { self: { href: '/test' } }, _embedded: { test: 'value' }, - location: 'client', extra_field: 'should be removed', }, ]; @@ -176,17 +151,17 @@ describe( 'Store Reducer', () => { // Should have valid properties expect( ability.name ).toBe( 'test/ability' ); expect( ability.label ).toBe( 'Test Ability' ); - expect( ability.description ).toBe( 'Full test ability' ); + expect( ability.description ).toBe( 'Full test ability.' ); expect( ability.input_schema ).toEqual( { type: 'object' } ); expect( ability.output_schema ).toEqual( { type: 'object' } ); - expect( ability.meta ).toEqual( { type: 'resource' } ); + expect( ability.annotations ).toEqual( { readonly: true } ); + expect( ability.meta ).toEqual( { category: 'test' } ); expect( ability.callback ).toBeDefined(); expect( ability.permissionCallback ).toBeDefined(); // Should NOT have invalid properties expect( ability ).not.toHaveProperty( '_links' ); expect( ability ).not.toHaveProperty( '_embedded' ); - expect( ability ).not.toHaveProperty( 'location' ); expect( ability ).not.toHaveProperty( 'extra_field' ); } ); } ); @@ -225,7 +200,6 @@ describe( 'Store Reducer', () => { description: 'Test ability', callback: () => Promise.resolve( {} ), // Extra properties that should be filtered out - location: 'client', _links: { self: { href: '/test' } }, extra_field: 'should be removed', }; @@ -249,7 +223,6 @@ describe( 'Store Reducer', () => { expect( registeredAbility.callback ).toBeDefined(); // Should NOT have invalid properties - expect( registeredAbility ).not.toHaveProperty( 'location' ); expect( registeredAbility ).not.toHaveProperty( '_links' ); expect( registeredAbility ).not.toHaveProperty( 'extra_field' ); } ); diff --git a/packages/client/src/store/reducer.ts b/packages/client/src/store/reducer.ts index 8e05fc63..df2998d5 100644 --- a/packages/client/src/store/reducer.ts +++ b/packages/client/src/store/reducer.ts @@ -23,6 +23,7 @@ const ABILITY_KEYS = [ 'description', 'input_schema', 'output_schema', + 'annotations', 'meta', 'callback', 'permissionCallback', diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index b663afa4..ecbdd0bc 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -68,15 +68,22 @@ export interface Ability { */ permissionCallback?: PermissionCallback; + /** + * Annotations for the ability. + * @see WP_Ability::get_annotations() + */ + annotations?: { + instructions?: string; + readonly?: boolean; + destructive?: boolean; + idempotent?: boolean; + }; + /** * Metadata about the ability. * @see WP_Ability::get_meta() */ meta?: { - /** - * The type of ability - 'resource' uses GET, 'tool' uses POST. - */ - type?: 'resource' | 'tool'; [ key: string ]: any; }; } diff --git a/tests/unit/abilities-api/wpAbilitiesRegistry.php b/tests/unit/abilities-api/wpAbilitiesRegistry.php index 94f46863..328d6949 100644 --- a/tests/unit/abilities-api/wpAbilitiesRegistry.php +++ b/tests/unit/abilities-api/wpAbilitiesRegistry.php @@ -266,6 +266,22 @@ public function test_register_incorrect_output_schema_type() { $this->assertNull( $result ); } + + /** + * Should reject ability registration with invalid `annotations` type. + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_annotations_type() { + self::$test_ability_args['annotations'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + /** * Should reject ability registration with invalid meta type. * diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 961a40f8..2ac5e213 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -26,15 +26,98 @@ public function set_up(): void { 'description' => 'The result of performing a math operation.', 'required' => true, ), + 'execute_callback' => static function (): int { + return 0; + }, 'permission_callback' => static function (): bool { return true; }, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), 'meta' => array( 'category' => 'math', ), ); } + /** + * Tests getting all annotations when selective overrides are applied. + */ + public function test_get_all_annotations() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertEquals( + array_merge( + self::$test_ability_properties['annotations'], + array( + 'instructions' => '', + 'idempotent' => false, + ), + ), + $ability->get_annotations() + ); + } + + /** + * Tests getting default annotations when not provided. + */ + public function test_get_default_annotations() { + $args = self::$test_ability_properties; + unset( $args['annotations'] ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( + array( + 'instructions' => '', + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, + ), + $ability->get_annotations() + ); + } + + /** + * Tests getting all annotations when values overridden. + */ + public function test_get_all_annotations_overridden() { + $annotations = array( + 'instructions' => 'Enjoy responsibly.', + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, + ); + $args = array_merge( + self::$test_ability_properties, + array( + 'annotations' => $annotations, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $annotations, $ability->get_annotations() ); + } + /** + * Tests that invalid annotations throw an exception. + */ + public function test_annotations_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'annotations' => 5, + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability properties should provide a valid `annotations` array.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + /** * Data provider for testing the execution of the ability. */ @@ -346,9 +429,6 @@ public function test_actions_not_fired_on_permission_failure() { 'permission_callback' => static function (): bool { return false; }, - 'execute_callback' => static function (): int { - return 42; - }, ) ); diff --git a/tests/unit/abilities-api/wpRegisterAbility.php b/tests/unit/abilities-api/wpRegisterAbility.php index a292a118..b4e7bd77 100644 --- a/tests/unit/abilities-api/wpRegisterAbility.php +++ b/tests/unit/abilities-api/wpRegisterAbility.php @@ -60,6 +60,10 @@ public function set_up(): void { 'permission_callback' => static function (): bool { return true; }, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), 'meta' => array( 'category' => 'math', ), @@ -132,6 +136,16 @@ public function test_register_valid_ability(): void { $this->assertSame( self::$test_ability_args['description'], $result->get_description() ); $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() ); $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() ); + $this->assertEquals( + array_merge( + array( + 'instructions' => '', + 'idempotent' => false, + ), + self::$test_ability_args['annotations'], + ), + $result->get_annotations() + ); $this->assertSame( self::$test_ability_args['meta'], $result->get_meta() ); $this->assertTrue( $result->check_permissions( diff --git a/tests/unit/rest-api/wpRestAbilitiesListController.php b/tests/unit/rest-api/wpRestAbilitiesListController.php index 319e26e0..72f428b9 100644 --- a/tests/unit/rest-api/wpRestAbilitiesListController.php +++ b/tests/unit/rest-api/wpRestAbilitiesListController.php @@ -84,7 +84,7 @@ public function tear_down(): void { * Register test abilities for testing. */ private function register_test_abilities(): void { - // Register a tool ability + // Register a regular ability. wp_register_ability( 'test/calculator', array( @@ -122,13 +122,12 @@ private function register_test_abilities(): void { return current_user_can( 'read' ); }, 'meta' => array( - 'type' => 'tool', 'category' => 'math', ), ) ); - // Register a resource ability + // Register a read-only ability. wp_register_ability( 'test/system-info', array( @@ -164,8 +163,10 @@ private function register_test_abilities(): void { 'permission_callback' => static function () { return current_user_can( 'read' ); }, + 'annotations' => array( + 'readonly' => true, + ), 'meta' => array( - 'type' => 'resource', 'category' => 'system', ), ) @@ -223,7 +224,7 @@ public function test_get_item(): void { $this->assertArrayHasKey( 'input_schema', $data ); $this->assertArrayHasKey( 'output_schema', $data ); $this->assertArrayHasKey( 'meta', $data ); - $this->assertEquals( 'tool', $data['meta']['type'] ); + $this->assertEquals( 'math', $data['meta']['category'] ); } /** diff --git a/tests/unit/rest-api/wpRestAbilitiesRunController.php b/tests/unit/rest-api/wpRestAbilitiesRunController.php index f7ae2195..957001d0 100644 --- a/tests/unit/rest-api/wpRestAbilitiesRunController.php +++ b/tests/unit/rest-api/wpRestAbilitiesRunController.php @@ -91,7 +91,7 @@ public function tear_down(): void { * Register test abilities for testing. */ private function register_test_abilities(): void { - // Tool ability (POST only) + // Regular ability (POST only). wp_register_ability( 'test/calculator', array( @@ -121,13 +121,10 @@ private function register_test_abilities(): void { 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, - 'meta' => array( - 'type' => 'tool', - ), ) ); - // Resource ability (GET only) + // Read-only ability (GET method). wp_register_ability( 'test/user-info', array( @@ -163,8 +160,8 @@ private function register_test_abilities(): void { 'permission_callback' => static function () { return is_user_logged_in(); }, - 'meta' => array( - 'type' => 'resource', + 'annotations' => array( + 'readonly' => true, ), ) ); @@ -193,9 +190,6 @@ private function register_test_abilities(): void { // Only allow if secret matches return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; }, - 'meta' => array( - 'type' => 'tool', - ), ) ); @@ -209,9 +203,6 @@ private function register_test_abilities(): void { return null; }, 'permission_callback' => '__return_true', - 'meta' => array( - 'type' => 'tool', - ), ) ); @@ -225,9 +216,6 @@ private function register_test_abilities(): void { return new \WP_Error( 'test_error', 'This is a test error' ); }, 'permission_callback' => '__return_true', - 'meta' => array( - 'type' => 'tool', - ), ) ); @@ -244,13 +232,10 @@ private function register_test_abilities(): void { return 'not a number'; // Invalid - schema expects number }, 'permission_callback' => '__return_true', - 'meta' => array( - 'type' => 'tool', - ), ) ); - // Resource ability for query params testing + // Read-only ability for query params testing. wp_register_ability( 'test/query-params', array( @@ -267,17 +252,17 @@ private function register_test_abilities(): void { return $input; }, 'permission_callback' => '__return_true', - 'meta' => array( - 'type' => 'resource', + 'annotations' => array( + 'readonly' => true, ), ) ); } /** - * Test executing a tool ability with POST. + * Test executing a regular ability with POST. */ - public function test_execute_tool_ability_post(): void { + public function test_execute_regular_ability_post(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/calculator/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -298,9 +283,9 @@ public function test_execute_tool_ability_post(): void { } /** - * Test executing a resource ability with GET. + * Test executing a read-only ability with GET. */ - public function test_execute_resource_ability_get(): void { + public function test_execute_readonly_ability_get(): void { $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/user-info/run' ); $request->set_query_params( array( @@ -318,9 +303,9 @@ public function test_execute_resource_ability_get(): void { } /** - * Test HTTP method validation for tool abilities. + * Test HTTP method validation for regular abilities. */ - public function test_tool_ability_requires_post(): void { + public function test_regular_ability_requires_post(): void { wp_register_ability( 'test/open-tool', array( @@ -330,9 +315,6 @@ public function test_tool_ability_requires_post(): void { return 'success'; }, 'permission_callback' => '__return_true', - 'meta' => array( - 'type' => 'tool', - ), ) ); @@ -342,14 +324,14 @@ public function test_tool_ability_requires_post(): void { $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); } /** - * Test HTTP method validation for resource abilities. + * Test HTTP method validation for read-only abilities. */ - public function test_resource_ability_requires_get(): void { - // Try POST on a resource ability (should fail) + public function test_readonly_ability_requires_get(): void { + // Try POST on a read-only ability (should fail). $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/user-info/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); @@ -359,7 +341,7 @@ public function test_resource_ability_requires_get(): void { $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Resource abilities require GET method.', $data['message'] ); + $this->assertSame( 'Read-only abilities require GET method.', $data['message'] ); } @@ -609,7 +591,6 @@ public function test_output_validation_failure_returns_error(): void { return array( 'wrong_field' => 'value' ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), ) ); @@ -651,7 +632,6 @@ public function test_input_validation_failure_returns_error(): void { return array( 'status' => 'success' ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), ) ); @@ -673,30 +653,29 @@ public function test_input_validation_failure_returns_error(): void { } /** - * Test ability type not set defaults to tool. + * Test ability without annotations defaults to POST method. */ - public function test_ability_without_type_defaults_to_tool(): void { - // Register ability without type in meta. + public function test_ability_without_annotations_defaults_to_post_method(): void { + // Register ability without annotations. wp_register_ability( - 'test/no-type', + 'test/no-annotations', array( - 'label' => 'No Type', - 'description' => 'Ability without type', + 'label' => 'No Annotations', + 'description' => 'Ability without annotations.', 'execute_callback' => static function () { return array( 'executed' => true ); }, 'permission_callback' => '__return_true', - 'meta' => array(), // No type specified ) ); - // Should require POST (default tool behavior) - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-type/run' ); + // Should require POST (default behavior). + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-annotations/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 405, $get_response->get_status() ); - // Should work with POST - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-type/run' ); + // Should work with POST. + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-annotations/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_response = $this->server->dispatch( $post_request ); @@ -704,44 +683,45 @@ public function test_ability_without_type_defaults_to_tool(): void { } /** - * Test edge case with empty input for both GET and POST. + * Test edge case with empty input for both GET and POST methods. */ public function test_empty_input_handling(): void { // Registers abilities for empty input testing. wp_register_ability( - 'test/resource-empty', + 'test/read-only-empty', array( - 'label' => 'Resource Empty', - 'description' => 'Resource with empty input', + 'label' => 'Read-only Empty', + 'description' => 'Read-only with empty input.', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'resource' ), + 'annotations' => array( + 'readonly' => true, + ), ) ); wp_register_ability( - 'test/tool-empty', + 'test/regular-empty', array( - 'label' => 'Tool Empty', - 'description' => 'Tool with empty input', + 'label' => 'Regular Empty', + 'description' => 'Regular with empty input.', 'execute_callback' => static function () { return array( 'input_was_empty' => 0 === func_num_args() ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), ) ); // Tests GET with no input parameter. - $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/resource-empty/run' ); + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/read-only-empty/run' ); $get_response = $this->server->dispatch( $get_request ); $this->assertEquals( 200, $get_response->get_status() ); $this->assertTrue( $get_response->get_data()['input_was_empty'] ); // Tests POST with no body. - $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/tool-empty/run' ); + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/regular-empty/run' ); $post_request->set_header( 'Content-Type', 'application/json' ); $post_request->set_body( '{}' ); // Empty JSON object @@ -803,7 +783,6 @@ public function test_php_type_strings_in_input(): void { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), ) ); @@ -847,7 +826,6 @@ public function test_mixed_encoding_in_input(): void { return array( 'echo' => $input ); }, 'permission_callback' => '__return_true', - 'meta' => array( 'type' => 'tool' ), ) ); @@ -907,18 +885,17 @@ public function test_invalid_http_methods( string $method ): void { return array( 'success' => true ); }, 'permission_callback' => '__return_true', // No permission requirements - 'meta' => array( 'type' => 'tool' ), ) ); $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); $response = $this->server->dispatch( $request ); - // Tool abilities should only accept POST, so these should return 405. + // Regular abilities should only accept POST, so these should return 405. $this->assertSame( 405, $response->get_status() ); $data = $response->get_data(); $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); - $this->assertSame( 'Tool abilities require POST method.', $data['message'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); } /**