| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * REST API run controller for Abilities API. 4 * 5 * @package WordPress 6 * @subpackage Abilities_API 7 * @since 6.9.0 8 */ 9 10 declare( strict_types = 1 ); 11 12 /** 13 * Core controller used to execute abilities via the REST API. 14 * 15 * @since 6.9.0 16 * 17 * @see WP_REST_Controller 18 */ 19 class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller { 20 21 /** 22 * REST API namespace. 23 * 24 * @since 6.9.0 25 * @var string 26 */ 27 protected $namespace = 'wp-abilities/v1'; 28 29 /** 30 * REST API base route. 31 * 32 * @since 6.9.0 33 * @var string 34 */ 35 protected $rest_base = 'abilities'; 36 37 /** 38 * Registers the routes for ability execution. 39 * 40 * @since 6.9.0 41 * 42 * @see register_rest_route() 43 */ 44 public function register_routes(): void { 45 register_rest_route( 46 $this->namespace, 47 '/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+?)/run', 48 array( 49 'args' => array( 50 'name' => array( 51 'description' => __( 'Unique identifier for the ability.' ), 52 'type' => 'string', 53 'pattern' => '^[a-zA-Z0-9\-\/]+$', 54 ), 55 ), 56 57 // TODO: We register ALLMETHODS because at route registration time, we don't know which abilities 58 // exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress 59 // load order - routes are registered early, before plugins have registered their abilities. 60 // This approach works but could be improved with lazy route registration or a different 61 // architecture that allows type-specific routes after abilities are registered. 62 // This was the same issue that we ended up seeing with the Feature API. 63 array( 64 'methods' => WP_REST_Server::ALLMETHODS, 65 'callback' => array( $this, 'execute_ability' ), 66 'permission_callback' => array( $this, 'check_ability_permissions' ), 67 'args' => $this->get_run_args(), 68 ), 69 'schema' => array( $this, 'get_run_schema' ), 70 ) 71 ); 72 } 73 74 /** 75 * Executes an ability. 76 * 77 * @since 6.9.0 78 * 79 * @param WP_REST_Request $request Full details about the request. 80 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. 81 */ 82 public function execute_ability( $request ) { 83 $ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null; 84 if ( ! $ability ) { 85 return new WP_Error( 86 'rest_ability_not_found', 87 __( 'Ability not found.' ), 88 array( 'status' => 404 ) 89 ); 90 } 91 92 $input = $this->get_input_from_request( $request ); 93 $result = $ability->execute( $input ); 94 if ( is_wp_error( $result ) ) { 95 return $result; 96 } 97 98 return rest_ensure_response( $result ); 99 } 100 101 /** 102 * Validates if the HTTP method matches the expected method for the ability based on its annotations. 103 * 104 * @since 6.9.0 105 * 106 * @param string $request_method The HTTP method of the request. 107 * @param array<string, (null|bool)> $annotations The ability annotations. 108 * @return true|WP_Error True on success, or WP_Error object on failure. 109 */ 110 public function validate_request_method( string $request_method, array $annotations ) { 111 $expected_method = 'POST'; 112 if ( ! empty( $annotations['readonly'] ) ) { 113 $expected_method = 'GET'; 114 } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { 115 $expected_method = 'DELETE'; 116 } 117 118 if ( $expected_method === $request_method ) { 119 return true; 120 } 121 122 $error_message = __( 'Abilities that perform updates require POST method.' ); 123 if ( 'GET' === $expected_method ) { 124 $error_message = __( 'Read-only abilities require GET method.' ); 125 } elseif ( 'DELETE' === $expected_method ) { 126 $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); 127 } 128 return new WP_Error( 129 'rest_ability_invalid_method', 130 $error_message, 131 array( 'status' => 405 ) 132 ); 133 } 134 135 /** 136 * Checks if a given request has permission to execute a specific ability. 137 * 138 * @since 6.9.0 139 * 140 * @param WP_REST_Request $request Full details about the request. 141 * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. 142 */ 143 public function check_ability_permissions( $request ) { 144 $ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null; 145 if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { 146 return new WP_Error( 147 'rest_ability_not_found', 148 __( 'Ability not found.' ), 149 array( 'status' => 404 ) 150 ); 151 } 152 153 $is_valid = $this->validate_request_method( 154 $request->get_method(), 155 $ability->get_meta_item( 'annotations' ) 156 ); 157 if ( is_wp_error( $is_valid ) ) { 158 return $is_valid; 159 } 160 161 $input = $this->get_input_from_request( $request ); 162 $input = $ability->normalize_input( $input ); 163 if ( is_wp_error( $input ) ) { 164 return $this->ensure_error_status( $input, 400 ); 165 } 166 167 $is_valid = $ability->validate_input( $input ); 168 if ( is_wp_error( $is_valid ) ) { 169 return $this->ensure_error_status( $is_valid, 400 ); 170 } 171 172 $result = $ability->check_permissions( $input ); 173 if ( is_wp_error( $result ) ) { 174 $result->add_data( array( 'status' => rest_authorization_required_code() ) ); 175 return $result; 176 } 177 if ( ! $result ) { 178 return new WP_Error( 179 'rest_ability_cannot_execute', 180 __( 'Sorry, you are not allowed to execute this ability.' ), 181 array( 'status' => rest_authorization_required_code() ) 182 ); 183 } 184 185 return true; 186 } 187 188 /** 189 * Ensures a WP_Error object carries an HTTP status, adding a default when none is set. 190 * 191 * @since 7.1.0 192 * 193 * @param WP_Error $error Error object to update. 194 * @param int $status HTTP status code to add if not already present. 195 * @return WP_Error The error object, with a default status when needed. 196 */ 197 private function ensure_error_status( WP_Error $error, int $status ): WP_Error { 198 $error_data = $error->get_error_data(); 199 if ( ! is_array( $error_data ) || ! isset( $error_data['status'] ) ) { 200 $error->add_data( array( 'status' => $status ) ); 201 } 202 203 return $error; 204 } 205 206 /** 207 * Extracts input parameters from the request. 208 * 209 * @since 6.9.0 210 * 211 * @param WP_REST_Request $request The request object. 212 * @return mixed|null The input parameters. 213 */ 214 private function get_input_from_request( $request ) { 215 if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { 216 // For GET and DELETE requests, look for 'input' query parameter. 217 $query_params = $request->get_query_params(); 218 return $query_params['input'] ?? null; 219 } 220 221 // For POST requests, look for 'input' in JSON body. 222 $json_params = $request->get_json_params(); 223 return $json_params['input'] ?? null; 224 } 225 226 /** 227 * Sanitizes the run input by coercing it to the ability's input schema. 228 * 229 * Registered as the `input` argument `sanitize_callback` so that both 230 * `check_ability_permissions()` and `execute_ability()` receive natively typed input 231 * regardless of transport. 232 * 233 * @since 7.1.0 234 * 235 * @see WP_REST_Abilities_V1_Run_Controller::coerce_input_to_schema() 236 * 237 * @param mixed $input Raw input extracted from the request. 238 * @param WP_REST_Request $request The request object. 239 * @return mixed Coerced input, or the raw input when it cannot be safely coerced. 240 */ 241 public function sanitize_input_for_ability( $input, $request ) { 242 $ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null; 243 if ( ! $ability instanceof WP_Ability ) { 244 return $input; 245 } 246 247 return $this->coerce_input_to_schema( $input, $ability ); 248 } 249 250 /** 251 * Coerces raw request input to the types declared in the ability input schema. 252 * 253 * GET and DELETE deliver every scalar as a string ("10", "true") and a list as a single 254 * comma-separated string, so without coercion an ability receives raw strings where its 255 * schema declares integers, booleans, or arrays. 256 * 257 * Coercion never changes what validation accepts. Input is coerced only when 258 * {@see WP_Ability::validate_input()} already accepts it, and any error surfaced while 259 * sanitizing falls back to the raw input, so `validate_input()` stays the single authority 260 * on what is rejected. 261 * 262 * @since 7.1.0 263 * 264 * @param mixed $input Raw input extracted from the request. 265 * @param WP_Ability $ability The ability being executed. 266 * @return mixed Coerced input, or the raw input when it cannot be safely coerced. 267 */ 268 private function coerce_input_to_schema( $input, WP_Ability $ability ) { 269 if ( null === $input ) { 270 return $input; 271 } 272 273 $schema = $ability->get_input_schema(); 274 if ( empty( $schema ) ) { 275 return $input; 276 } 277 278 /* 279 * Only coerce input that already validates. Sanitizing invalid input can silently 280 * change which values are accepted -- `additionalProperties: false` strips unknown 281 * keys, and a non-numeric string casts to 0 -- so leaving invalid input untouched 282 * lets validate_input() reject it exactly as it does without coercion. 283 * 284 * validate_input() is asked rather than rest_validate_value_from_schema() so that the 285 * `wp_ability_validate_input` filter decides what counts as valid here as well. A filter 286 * that overrides a schema failure accepts the input, so the input is coerced; a filter 287 * that rejects otherwise valid input leaves it untouched for validate_input() to report. 288 */ 289 if ( is_wp_error( $ability->validate_input( $input ) ) ) { 290 return $input; 291 } 292 293 $sanitized = rest_sanitize_value_from_schema( $input, $schema, 'input' ); 294 295 /* 296 * Sanitizing can still surface an error the lenient validation above did not, such as 297 * items that are unique as strings but collide once cast to integers (`uniqueItems`). 298 * The error may be returned at the top level or nested inside the returned array, so 299 * scan recursively and fall back to the raw input on any error. 300 */ 301 if ( $this->input_contains_error( $sanitized ) ) { 302 return $input; 303 } 304 305 return $sanitized; 306 } 307 308 /** 309 * Determines whether a sanitized value is, or contains, a WP_Error. 310 * 311 * @since 7.1.0 312 * 313 * @param mixed $value The value to inspect. 314 * @return bool True if the value is, or contains, a WP_Error. 315 */ 316 private function input_contains_error( $value ): bool { 317 if ( is_wp_error( $value ) ) { 318 return true; 319 } 320 321 if ( is_array( $value ) ) { 322 foreach ( $value as $item ) { 323 if ( $this->input_contains_error( $item ) ) { 324 return true; 325 } 326 } 327 } 328 329 return false; 330 } 331 332 /** 333 * Retrieves the arguments for ability execution endpoint. 334 * 335 * @since 6.9.0 336 * 337 * @return array<string, mixed> Arguments for the run endpoint. 338 */ 339 public function get_run_args(): array { 340 return array( 341 'input' => array( 342 'description' => __( 'Input parameters for the ability execution.' ), 343 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), 344 'default' => null, 345 'sanitize_callback' => array( $this, 'sanitize_input_for_ability' ), 346 ), 347 ); 348 } 349 350 /** 351 * Retrieves the schema for ability execution endpoint. 352 * 353 * @since 6.9.0 354 * 355 * @return array<string, mixed> Schema for the run endpoint. 356 */ 357 public function get_run_schema(): array { 358 return array( 359 '$schema' => 'http://json-schema.org/draft-04/schema#', 360 'title' => 'ability-execution', 361 'type' => 'object', 362 'properties' => array( 363 'result' => array( 364 'description' => __( 'The result of the ability execution.' ), 365 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), 366 'context' => array( 'view', 'edit' ), 367 'readonly' => true, 368 ), 369 ), 370 ); 371 } 372 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sun Jul 26 08:20:18 2026 | Cross-referenced by PHPXref |