Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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().
Expand Down
299 changes: 299 additions & 0 deletions tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
}
}
Loading