[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/rest-api/endpoints/ -> class-wp-rest-abilities-v1-run-controller.php (source)

   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  }


Generated : Sat Jul 25 08:20:20 2026 Cross-referenced by PHPXref