Skip to content

Commit e23c381

Browse files
committed
Improve support for annotations, including DELETE handling in REST API
1 parent 91b3f6b commit e23c381

File tree

6 files changed

+116
-34
lines changed

6 files changed

+116
-34
lines changed

src/wp-includes/abilities-api.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@
3838
* @type array<string, mixed> $meta {
3939
* Optional. Additional metadata for the ability.
4040
*
41-
* @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
42-
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
41+
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
42+
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
4343
* }
4444
* @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
4545
* }

src/wp-includes/abilities-api/class-wp-abilities-registry.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ final class WP_Abilities_Registry {
6161
* @type array<string, mixed> $meta {
6262
* Optional. Additional metadata for the ability.
6363
*
64-
* @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
65-
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
64+
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
65+
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
6666
* }
6767
* @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
6868
* }

src/wp-includes/abilities-api/class-wp-ability.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,21 @@ class WP_Ability {
3333
* They are not guaranteed to provide a faithful description of ability behavior.
3434
*
3535
* @since 6.9.0
36-
* @var array<string, (bool|string)>
36+
* @var array<string, (null|bool)>
3737
*/
3838
protected static $default_annotations = array(
3939
// If true, the ability does not modify its environment.
40-
'readonly' => false,
40+
'readonly' => null,
4141
/*
4242
* If true, the ability may perform destructive updates to its environment.
4343
* If false, the ability performs only additive updates.
4444
*/
45-
'destructive' => false,
45+
'destructive' => null,
4646
/*
4747
* If true, calling the ability repeatedly with the same arguments will have no additional effect
4848
* on its environment.
4949
*/
50-
'idempotent' => false,
50+
'idempotent' => null,
5151
);
5252

5353
/**
@@ -150,8 +150,8 @@ class WP_Ability {
150150
* @type array<string, mixed> $meta {
151151
* Optional. Additional metadata for the ability.
152152
*
153-
* @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
154-
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
153+
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
154+
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
155155
* }
156156
* }
157157
*/
@@ -205,8 +205,8 @@ public function __construct( string $name, array $args ) {
205205
* @type array<string, mixed> $meta {
206206
* Optional. Additional metadata for the ability.
207207
*
208-
* @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
209-
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
208+
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
209+
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
210210
* }
211211
* }
212212
* @return array<string, mixed> {
@@ -224,8 +224,8 @@ public function __construct( string $name, array $args ) {
224224
* @type array<string, mixed> $meta {
225225
* Additional metadata for the ability.
226226
*
227-
* @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
228-
* @type bool $show_in_rest Whether to expose this ability in the REST API. Default false.
227+
* @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
228+
* @type bool $show_in_rest Whether to expose this ability in the REST API. Default false.
229229
* }
230230
* }
231231
* @throws InvalidArgumentException if an argument is invalid.

src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,22 +91,24 @@ public function run_ability_with_method_check( $request ) {
9191
}
9292

9393
// Check if the HTTP method matches the ability annotations.
94-
$annotations = $ability->get_meta_item( 'annotations' );
95-
$is_readonly = ! empty( $annotations['readonly'] );
96-
$method = $request->get_method();
97-
98-
if ( $is_readonly && 'GET' !== $method ) {
99-
return new WP_Error(
100-
'rest_ability_invalid_method',
101-
__( 'Read-only abilities require GET method.' ),
102-
array( 'status' => 405 )
103-
);
94+
$annotations = $ability->get_meta_item( 'annotations' );
95+
$expected_method = 'POST';
96+
if ( ! empty( $annotations['readonly'] ) ) {
97+
$expected_method = 'GET';
98+
} elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) {
99+
$expected_method = 'DELETE';
104100
}
105101

106-
if ( ! $is_readonly && 'POST' !== $method ) {
102+
if ( $expected_method !== $request->get_method() ) {
103+
$error_message = __( 'Abilities that perform updates require POST method.' );
104+
if ( 'GET' === $expected_method ) {
105+
$error_message = __( 'Read-only abilities require GET method.' );
106+
} elseif ( 'DELETE' === $expected_method ) {
107+
$error_message = __( 'Abilities that perform destructive actions require DELETE method.' );
108+
}
107109
return new WP_Error(
108110
'rest_ability_invalid_method',
109-
__( 'Abilities that perform updates require POST method.' ),
111+
$error_message,
110112
array( 'status' => 405 )
111113
);
112114
}
@@ -183,8 +185,8 @@ public function run_ability_permissions_check( $request ) {
183185
* @return mixed|null The input parameters.
184186
*/
185187
private function get_input_from_request( $request ) {
186-
if ( 'GET' === $request->get_method() ) {
187-
// For GET requests, look for 'input' query parameter.
188+
if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ) ) ) {
189+
// For GET and DELETE requests, look for 'input' query parameter.
188190
$query_params = $request->get_query_params();
189191
return $query_params['input'] ?? null;
190192
}
@@ -226,7 +228,7 @@ public function get_run_schema(): array {
226228
'properties' => array(
227229
'result' => array(
228230
'description' => __( 'The result of the ability execution.' ),
229-
'context' => array( 'view' ),
231+
'context' => array( 'view', 'edit' ),
230232
'readonly' => true,
231233
),
232234
),

tests/phpunit/tests/abilities-api/wpAbility.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,11 @@ public function test_meta_get_non_existing_item_with_custom_default() {
111111
public function test_get_merged_annotations_from_meta() {
112112
$ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
113113

114-
$this->assertEquals(
114+
$this->assertSame(
115115
array_merge(
116116
self::$test_ability_properties['meta']['annotations'],
117117
array(
118-
'idempotent' => false,
118+
'idempotent' => null,
119119
)
120120
),
121121
$ability->get_meta_item( 'annotations' )
@@ -135,9 +135,9 @@ public function test_get_default_annotations_from_meta() {
135135

136136
$this->assertSame(
137137
array(
138-
'readonly' => false,
139-
'destructive' => false,
140-
'idempotent' => false,
138+
'readonly' => null,
139+
'destructive' => null,
140+
'idempotent' => null,
141141
),
142142
$ability->get_meta_item( 'annotations' )
143143
);

tests/phpunit/tests/rest-api/wpRestAbilitiesRunController.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,47 @@ private function register_test_abilities(): void {
228228
)
229229
);
230230

231+
// Destructive ability (DELETE method).
232+
wp_register_ability(
233+
'test/delete-user',
234+
array(
235+
'label' => 'Delete User',
236+
'description' => 'Deletes a user',
237+
'category' => 'system',
238+
'input_schema' => array(
239+
'type' => 'object',
240+
'properties' => array(
241+
'user_id' => array(
242+
'type' => 'integer',
243+
'default' => 0,
244+
),
245+
),
246+
),
247+
'output_schema' => array(
248+
'type' => 'string',
249+
'required' => true,
250+
),
251+
'execute_callback' => static function ( array $input ) {
252+
$user_id = $input['user_id'] ?? get_current_user_id();
253+
$user = get_user_by( 'id', $user_id );
254+
if ( ! $user ) {
255+
return new WP_Error( 'user_not_found', 'User not found' );
256+
}
257+
return 'User successfully deleted!';
258+
},
259+
'permission_callback' => static function () {
260+
return is_user_logged_in();
261+
},
262+
'meta' => array(
263+
'annotations' => array(
264+
'destructive' => true,
265+
'idempotent' => true,
266+
),
267+
'show_in_rest' => true,
268+
),
269+
)
270+
);
271+
231272
// Ability with contextual permissions
232273
wp_register_ability(
233274
'test/restricted',
@@ -402,6 +443,27 @@ public function test_execute_readonly_ability_get(): void {
402443
$this->assertEquals( self::$user_id, $data['id'] );
403444
}
404445

446+
/**
447+
* Test executing a destructive ability with GET.
448+
*
449+
* @ticket 64098
450+
*/
451+
public function test_execute_destructive_ability_delete(): void {
452+
$request = new WP_REST_Request( 'DELETE', '/wp/v2/abilities/test/delete-user/run' );
453+
$request->set_query_params(
454+
array(
455+
'input' => array(
456+
'user_id' => self::$user_id,
457+
),
458+
)
459+
);
460+
461+
$response = $this->server->dispatch( $request );
462+
463+
$this->assertEquals( 200, $response->get_status() );
464+
$this->assertEquals( 'User successfully deleted!', $response->get_data() );
465+
}
466+
405467
/**
406468
* Test HTTP method validation for regular abilities.
407469
*
@@ -452,6 +514,24 @@ public function test_readonly_ability_requires_get(): void {
452514
$this->assertSame( 'Read-only abilities require GET method.', $data['message'] );
453515
}
454516

517+
/**
518+
* Test HTTP method validation for destructive abilities.
519+
*
520+
* @ticket 64098
521+
*/
522+
public function test_destructive_ability_requires_delete(): void {
523+
// Try POST on a destructive ability (should fail).
524+
$request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/delete-user/run' );
525+
$request->set_header( 'Content-Type', 'application/json' );
526+
$request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) );
527+
528+
$response = $this->server->dispatch( $request );
529+
530+
$this->assertSame( 405, $response->get_status() );
531+
$data = $response->get_data();
532+
$this->assertSame( 'rest_ability_invalid_method', $data['code'] );
533+
$this->assertSame( 'Abilities that perform destructive actions require DELETE method.', $data['message'] );
534+
}
455535

456536
/**
457537
* Test output validation against schema.

0 commit comments

Comments
 (0)