| [ 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 /* 70 * Serve the ability result as returned, so it follows the ability's output 71 * schema whether it is an object, an array, or a scalar value: `_fields`, 72 * `_embed` and response links leave it unchanged. 73 */ 74 'data_processing' => false, 75 ), 76 'schema' => array( $this, 'get_run_schema' ), 77 ) 78 ); 79 } 80 81 /** 82 * Executes an ability. 83 * 84 * @since 6.9.0 85 * 86 * @param WP_REST_Request $request Full details about the request. 87 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. 88 */ 89 public function execute_ability( $request ) { 90 $ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null; 91 if ( ! $ability ) { 92 return new WP_Error( 93 'rest_ability_not_found', 94 __( 'Ability not found.' ), 95 array( 'status' => 404 ) 96 ); 97 } 98 99 $input = $this->get_input_from_request( $request ); 100 $result = $ability->execute( $input ); 101 if ( is_wp_error( $result ) ) { 102 return $result; 103 } 104 105 /* 106 * Preserve empty objects when encoding the result as JSON. Preparation can 107 * convert empty PHP arrays to stdClass, also within object results. 108 */ 109 $result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() ); 110 111 return rest_ensure_response( $result ); 112 } 113 114 /** 115 * Validates if the HTTP method matches the expected method for the ability based on its annotations. 116 * 117 * @since 6.9.0 118 * 119 * @param string $request_method The HTTP method of the request. 120 * @param array<string, (null|bool)> $annotations The ability annotations. 121 * @return true|WP_Error True on success, or WP_Error object on failure. 122 */ 123 public function validate_request_method( string $request_method, array $annotations ) { 124 $expected_method = 'POST'; 125 if ( ! empty( $annotations['readonly'] ) ) { 126 $expected_method = 'GET'; 127 } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { 128 $expected_method = 'DELETE'; 129 } 130 131 if ( $expected_method === $request_method ) { 132 return true; 133 } 134 135 $error_message = __( 'Abilities that perform updates require POST method.' ); 136 if ( 'GET' === $expected_method ) { 137 $error_message = __( 'Read-only abilities require GET method.' ); 138 } elseif ( 'DELETE' === $expected_method ) { 139 $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); 140 } 141 return new WP_Error( 142 'rest_ability_invalid_method', 143 $error_message, 144 array( 'status' => 405 ) 145 ); 146 } 147 148 /** 149 * Checks if a given request has permission to execute a specific ability. 150 * 151 * @since 6.9.0 152 * 153 * @param WP_REST_Request $request Full details about the request. 154 * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. 155 */ 156 public function check_ability_permissions( $request ) { 157 $ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null; 158 if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { 159 return new WP_Error( 160 'rest_ability_not_found', 161 __( 'Ability not found.' ), 162 array( 'status' => 404 ) 163 ); 164 } 165 166 $is_valid = $this->validate_request_method( 167 $request->get_method(), 168 $ability->get_meta_item( 'annotations' ) 169 ); 170 if ( is_wp_error( $is_valid ) ) { 171 return $is_valid; 172 } 173 174 $input = $this->get_input_from_request( $request ); 175 $input = $ability->normalize_input( $input ); 176 if ( is_wp_error( $input ) ) { 177 return $this->ensure_error_status( $input, 400 ); 178 } 179 180 $is_valid = $ability->validate_input( $input ); 181 if ( is_wp_error( $is_valid ) ) { 182 return $this->ensure_error_status( $is_valid, 400 ); 183 } 184 185 $input = $ability->coerce_input( $input ); 186 if ( is_wp_error( $input ) ) { 187 return $this->ensure_error_status( $input, 400 ); 188 } 189 190 $result = $ability->check_permissions( $input ); 191 if ( is_wp_error( $result ) ) { 192 $result->add_data( array( 'status' => rest_authorization_required_code() ) ); 193 return $result; 194 } 195 if ( ! $result ) { 196 return new WP_Error( 197 'rest_ability_cannot_execute', 198 __( 'Sorry, you are not allowed to execute this ability.' ), 199 array( 'status' => rest_authorization_required_code() ) 200 ); 201 } 202 203 return true; 204 } 205 206 /** 207 * Ensures a WP_Error object carries an HTTP status, adding a default when none is set. 208 * 209 * @since 7.1.0 210 * 211 * @param WP_Error $error Error object to update. 212 * @param int $status HTTP status code to add if not already present. 213 * @return WP_Error The error object, with a default status when needed. 214 */ 215 private function ensure_error_status( WP_Error $error, int $status ): WP_Error { 216 $error_data = $error->get_error_data(); 217 if ( ! is_array( $error_data ) || ! isset( $error_data['status'] ) ) { 218 $error->add_data( array( 'status' => $status ) ); 219 } 220 221 return $error; 222 } 223 224 /** 225 * Extracts input parameters from the request. 226 * 227 * @since 6.9.0 228 * 229 * @param WP_REST_Request $request The request object. 230 * @return mixed|null The input parameters. 231 */ 232 private function get_input_from_request( $request ) { 233 if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { 234 // For GET and DELETE requests, look for 'input' query parameter. 235 $query_params = $request->get_query_params(); 236 return $query_params['input'] ?? null; 237 } 238 239 // For POST requests, look for 'input' in JSON body. 240 $json_params = $request->get_json_params(); 241 return $json_params['input'] ?? null; 242 } 243 244 /** 245 * Sanitizes the run input by coercing it to the ability's input schema. 246 * 247 * Registered as the `input` argument `sanitize_callback` so request input is coerced 248 * before any route callback runs, regardless of transport. `check_ability_permissions()` 249 * and `WP_Ability::execute()` coerce the input again after normalization and validation, 250 * so schema defaults and values from normalization filters are coerced too. 251 * 252 * @since 7.1.0 253 * 254 * @see WP_Ability::coerce_input() 255 * 256 * @param mixed $input Raw input extracted from the request. 257 * @param WP_REST_Request $request The request object. 258 * @return mixed|WP_Error Coerced input, raw input when validation fails, or WP_Error when coercion fails. 259 */ 260 public function sanitize_input_for_ability( $input, $request ) { 261 $ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null; 262 if ( ! $ability instanceof WP_Ability ) { 263 return $input; 264 } 265 266 // Validate before coercion so sanitization cannot rescue invalid input. 267 if ( null === $input || empty( $ability->get_input_schema() ) || is_wp_error( $ability->validate_input( $input ) ) ) { 268 return $input; 269 } 270 271 return $ability->coerce_input( $input ); 272 } 273 274 /** 275 * Retrieves the arguments for ability execution endpoint. 276 * 277 * @since 6.9.0 278 * 279 * @return array<string, mixed> Arguments for the run endpoint. 280 */ 281 public function get_run_args(): array { 282 return array( 283 'input' => array( 284 'description' => __( 'Input parameters for the ability execution.' ), 285 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), 286 'default' => null, 287 'sanitize_callback' => array( $this, 'sanitize_input_for_ability' ), 288 ), 289 ); 290 } 291 292 /** 293 * Retrieves the schema for ability execution endpoint. 294 * 295 * @since 6.9.0 296 * 297 * @return array<string, mixed> Schema for the run endpoint. 298 */ 299 public function get_run_schema(): array { 300 return array( 301 '$schema' => 'http://json-schema.org/draft-04/schema#', 302 'title' => 'ability-execution', 303 'type' => 'object', 304 'properties' => array( 305 'result' => array( 306 'description' => __( 'The result of the ability execution.' ), 307 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), 308 'context' => array( 'view', 'edit' ), 309 'readonly' => true, 310 ), 311 ), 312 ); 313 } 314 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Wed Nov 4 08:20:38 2026 | Cross-referenced by PHPXref |