From 508a98f4daab5dcc277c07059bcea0da24795eb1 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 29 May 2026 18:47:40 +0200 Subject: [PATCH] Abilities API: Normalize `required` schema shape for REST responses When exposing an ability's input and output schemas through the REST API, convert any draft-03 per-property `required` boolean into a draft-04 `required` array of property names on the parent object schema, and drop boolean `required` flags that have no draft-04 equivalent (such as on a scalar root schema). WordPress accepts both forms internally, but only the array form is valid JSON Schema draft-04, so clients consuming `wp-abilities/v1` now receive a conformant schema. The transformation only affects the REST response copy; server-side validation continues to use the stored schema via validate_input(). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-wp-rest-abilities-v1-list-controller.php | 34 +- .../wpRestAbilitiesV1ListController.php | 299 ++++++++++++++++++ 2 files changed, 332 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 85464fd9dd302..87fa3c6f043aa 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -217,12 +217,21 @@ private function is_associative_array( $value ): bool { /** * Transforms an ability schema for REST response output. * + * The input and output schemas are a public contract: REST clients (such as + * the `@wordpress/abilities` JS client) consume them as standard JSON Schema + * and validate ability input and output against them. The response must + * therefore use JSON Schema draft-04 forms that standard validators + * understand, not the WordPress-internal conventions that + * `rest_validate_value_from_schema()` also accepts on the server. + * * Ability schemas may include WordPress-internal properties like * `sanitize_callback`, `validate_callback`, and `arg_options` that are * used server-side but are not valid JSON Schema keywords. This method * removes those specific keys so they are not exposed in REST responses. * It also converts empty array defaults to objects when the schema type is - * 'object' to ensure proper JSON serialization as {} instead of []. + * 'object' to ensure proper JSON serialization as {} instead of [], and + * normalizes the `required` keyword from the draft-03 per-property boolean + * form into the draft-04 array of property names. * * @since 7.1.0 * @@ -239,6 +248,29 @@ private function prepare_schema_for_response( array $schema ): array { $schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS ); + // Collect draft-03 per-property `required: true` flags into a draft-04 + // `required` array of property names on the parent object schema. + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + $required = ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) ? $schema['required'] : array(); + foreach ( $schema['properties'] as $property => $property_schema ) { + if ( $this->is_associative_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) { + if ( true === $property_schema['required'] ) { + $required[] = $property; + } + unset( $schema['properties'][ $property ]['required'] ); + } + } + if ( ! empty( $required ) ) { + $schema['required'] = array_values( array_unique( $required ) ); + } + } + + // A boolean `required` outside of an object's property list has no draft-04 + // equivalent, so drop it rather than emit an invalid keyword. + if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) { + unset( $schema['required'] ); + } + // Sub-schema maps: keys are user-defined, values are sub-schemas. // Note: 'dependencies' values can also be property-dependency arrays // (numeric arrays of strings) which are skipped via wp_is_numeric_array(). diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 5ba688cb57c79..cc1b9ba21cfd4 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -1123,4 +1123,303 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema']['additionalItems'] ); $this->assertSame( 'boolean', $data['output_schema']['additionalItems']['type'] ); } + + /** + * Test that per-property `required` booleans become a draft-04 `required` array. + * + * @ticket 64955 + */ + public function test_required_property_booleans_converted_to_draft_04_array(): void { + $this->register_test_ability( + 'test/required-booleans', + array( + 'label' => 'Required Booleans', + 'description' => 'Tests conversion of per-property required booleans.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'required' => true, + ), + 'content' => array( + 'type' => 'string', + 'required' => true, + ), + 'optional' => array( + 'type' => 'string', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ), + 'execute_callback' => static function (): array { + return array( 'id' => 1 ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-booleans' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // The `required` array lists the names of the properties flagged as required. + $this->assertArrayHasKey( 'required', $data['input_schema'] ); + $this->assertSameSets( array( 'title', 'content' ), $data['input_schema']['required'] ); + + // The boolean flag is removed from each property sub-schema. + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['title'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['content'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['optional'] ); + + // Output schemas are normalized the same way. + $this->assertSame( array( 'id' ), $data['output_schema']['required'] ); + $this->assertArrayNotHasKey( 'required', $data['output_schema']['properties']['id'] ); + } + + /** + * Test that per-property `required` booleans are converted in nested object schemas. + * + * @ticket 64955 + */ + public function test_required_booleans_converted_in_nested_object_schemas(): void { + $this->register_test_ability( + 'test/required-nested', + array( + 'label' => 'Required Nested', + 'description' => 'Tests conversion within nested object schemas.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'address' => array( + 'type' => 'object', + 'required' => true, + 'properties' => array( + 'street' => array( + 'type' => 'string', + 'required' => true, + ), + 'city' => array( + 'type' => 'string', + ), + ), + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-nested' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $address = $data['input_schema']['properties']['address']; + + // The outer object lists the nested object as a required property. + $this->assertSame( array( 'address' ), $data['input_schema']['required'] ); + + // The nested object's own boolean flag is replaced by a draft-04 array + // collecting its own required properties (proving the boolean was converted). + $this->assertSame( array( 'street' ), $address['required'] ); + $this->assertArrayNotHasKey( 'required', $address['properties']['street'] ); + $this->assertArrayNotHasKey( 'required', $address['properties']['city'] ); + } + + /** + * Test that `required: false` is removed without emitting an empty `required` array. + * + * @ticket 64955 + */ + public function test_required_false_booleans_removed_without_required_array(): void { + $this->register_test_ability( + 'test/required-false', + array( + 'label' => 'Required False', + 'description' => 'Tests that required:false is stripped.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'maybe' => array( + 'type' => 'string', + 'required' => false, + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-false' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'required', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'required', $data['input_schema']['properties']['maybe'] ); + } + + /** + * Test that a draft-04 `required` array and per-property booleans merge without duplicates. + * + * @ticket 64955 + */ + public function test_required_draft_04_array_merged_with_booleans(): void { + $this->register_test_ability( + 'test/required-mixed', + array( + 'label' => 'Required Mixed', + 'description' => 'Tests merging of a draft-04 array with draft-03 booleans.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'title' ), + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'required' => true, + ), + 'content' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-mixed' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // 'title' is listed in the array and flagged via boolean, but not duplicated. + $this->assertSameSets( array( 'title', 'content' ), $data['input_schema']['required'] ); + $this->assertCount( 2, $data['input_schema']['required'] ); + } + + /** + * Test that a boolean `required` with no draft-04 equivalent (e.g. on a scalar) is dropped. + * + * @ticket 64955 + */ + public function test_required_boolean_on_scalar_schema_removed(): void { + $this->register_test_ability( + 'test/required-scalar', + array( + 'label' => 'Required Scalar', + 'description' => 'Tests stripping of a boolean required on a scalar schema.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'string', + 'description' => 'The text to analyze.', + 'required' => true, + ), + 'output_schema' => array( + 'type' => 'string', + 'required' => true, + ), + 'execute_callback' => static function ( $input ) { + return $input; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-scalar' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'required', $data['input_schema'] ); + $this->assertSame( 'string', $data['input_schema']['type'] ); + $this->assertArrayNotHasKey( 'required', $data['output_schema'] ); + } + + /** + * Test that per-property `required` booleans are converted in an array's `items` object. + * + * @ticket 64955 + */ + public function test_required_booleans_converted_in_array_items_object_schemas(): void { + $this->register_test_ability( + 'test/required-array-items', + array( + 'label' => 'Required Array Items', + 'description' => 'Tests conversion within array item object schemas.', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'required' => true, + ), + 'label' => array( + 'type' => 'string', + ), + ), + ), + ), + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/required-array-items' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $items = $data['input_schema']['items']; + + // The object schema inside `items` collects its own required properties + // into a draft-04 array, and the per-property boolean is removed. + $this->assertSame( array( 'id' ), $items['required'] ); + $this->assertArrayNotHasKey( 'required', $items['properties']['id'] ); + $this->assertArrayNotHasKey( 'required', $items['properties']['label'] ); + } }