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
72 changes: 72 additions & 0 deletions src/wp-includes/json-schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
/**
* JSON Schema API: shared functions for working with JSON Schema.
*
* @package WordPress
* @subpackage JSON_Schema
* @since 7.1.0
*/

/**
* Gets the JSON Schema keywords allowed for a given schema profile.
*
* Use the returned list to decide which keywords to keep when a schema is
* output as JSON. Both profiles describe JSON Schema draft-04 output, also
* called JSON Schema Version 4. They differ only in how much of the keyword
* vocabulary stays in the result.
*
* - 'rest-api' returns the subset of draft-04 that the WordPress REST API
* uses for route output. This is the default.
* - 'draft-04' returns the larger draft-04 set used when publishing a schema
* as a standalone document to clients, such as the Abilities API. It keeps
* documentation and passthrough keywords like '$ref', 'definitions',
* 'allOf', 'not', 'dependencies', and 'additionalItems'.
*
* The keywords are allowed to stay in the schema output. This does not mean
* WordPress validates or sanitizes values against them.
*
* @since 7.1.0
*
* @param string $schema_profile Optional. Name of the schema profile to get keywords for.
* Accepts 'rest-api' or 'draft-04'. Any other value falls
* back to the 'rest-api' profile. Default 'rest-api'.
* @return string[] Allowed JSON Schema keywords.
*/
function wp_get_json_schema_allowed_keywords( string $schema_profile = 'rest-api' ): array {
$rest_keywords = rest_get_allowed_schema_keywords();

$keywords_by_profile = array(
'rest-api' => $rest_keywords,
'draft-04' => array_merge(
array(
'$schema',
'id',
'$ref',
),
$rest_keywords,
array(
'required',
'allOf',
'not',
'definitions',
'dependencies',
'additionalItems',
)
),
);

$allowed_keywords = $keywords_by_profile[ $schema_profile ] ?? $rest_keywords;

/**
* Filters the JSON Schema keywords allowed for a given schema profile.
*
* Adding a keyword lets it stay in the schema output for that profile.
* It does not make WordPress validate or sanitize values against the keyword.
*
* @since 7.1.0
*
* @param string[] $allowed_keywords Allowed JSON Schema keywords.
* @param string $schema_profile The schema profile the keywords are for.
*/
return apply_filters( 'wp_json_schema_allowed_keywords', $allowed_keywords, $schema_profile );
}
2 changes: 1 addition & 1 deletion src/wp-includes/rest-api/class-wp-rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -1649,7 +1649,7 @@ public function get_data_for_route( $route, $callbacks, $context = 'view' ) {
}
}

$allowed_schema_keywords = array_flip( rest_get_allowed_schema_keywords() );
$allowed_schema_keywords = array_flip( wp_get_json_schema_allowed_keywords( 'rest-api' ) );

$route = preg_replace( '#\(\?P<(\w+?)>.*?\)#', '{$1}', $route );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller {
*/
protected $rest_base = 'abilities';

/**
* Lookup map of allowed schema keywords for preparing ability schemas in REST responses.
*
* Keyword names are stored as keys so they can be matched with
* array_intersect_key(). Computed lazily on first use and reused while
* preparing nested schemas.
*
* @since 7.1.0
* @var array<string, true>
*/
private array $allowed_schema_keyword_lookup;

/**
* Registers the routes for abilities.
*
Expand Down Expand Up @@ -188,26 +200,6 @@ public function get_item_permissions_check( $request ) {
return current_user_can( 'read' );
}

/**
* Additional schema keywords to preserve in REST responses.
*
* Ability schemas are exposed to clients as JSON Schema. Preserve additional
* draft-04 keywords so clients can validate richer schemas, even when some
* of those keywords are not enforced by the server-side REST schema validator.
*
* @since 7.1.0
* @var string[]
*/
private const ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS = array(
'required',
'allOf',
'not',
'$ref',
'definitions',
'dependencies',
'additionalItems',
);

/**
* Determines whether the value is an associative array.
*
Expand All @@ -222,6 +214,26 @@ private function is_associative_array( $value ): bool {
return is_array( $value ) && ! wp_is_numeric_array( $value );
}

/**
* Gets the allowed schema keywords for preparing ability schemas in REST responses.
*
* Uses the fuller draft-04 keyword set, not the smaller REST API subset.
* The published schema is consumed by clients that re-validate values
* against standard draft-04, so it keeps the keywords those validators
* expect.
*
* @since 7.1.0
*
* @return array<string, true> Allowed schema keywords.
*/
private function get_allowed_schema_keywords_for_response(): array {
if ( ! isset( $this->allowed_schema_keyword_lookup ) ) {
$this->allowed_schema_keyword_lookup = array_fill_keys( wp_get_json_schema_allowed_keywords( 'draft-04' ), true );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fully familiar with this area of WP, but draft-04 looks strange, are you sure this is correct?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REST API uses a subset of draft-04 with elements from draft-03 JSON Schema specification. Abilities API allows using the semantic format for input and output schema as a REST API, but it transforms it to draft-04 when sending through the REST API to ensure that on the client, it gets the same validation against stricter draft-04.

The intent is correct, and the naming can have adjustments based on the feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a pass on the code documentation to explain it better in 435808d.

}

return $this->allowed_schema_keyword_lookup;
}

/**
* Transforms an ability schema for REST response output.
*
Expand Down Expand Up @@ -253,17 +265,7 @@ private function prepare_schema_for_response( array $schema ): array {
}
}

// Computed once and reused across the recursive calls for every schema node.
static $allowed_keywords = null;
$allowed_keywords ??= array_fill_keys(
array_merge(
rest_get_allowed_schema_keywords(),
self::ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS
),
true
);

$schema = array_intersect_key( $schema, $allowed_keywords );
$schema = array_intersect_key( $schema, $this->get_allowed_schema_keywords_for_response() );

// Collect draft-03 per-property `required: true` flags into a draft-04
// `required` array of property names on the parent object schema.
Expand Down
1 change: 1 addition & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@
require ABSPATH . WPINC . '/abilities-api.php';
require ABSPATH . WPINC . '/abilities.php';
require ABSPATH . WPINC . '/rest-api.php';
require ABSPATH . WPINC . '/json-schema.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-request.php';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,7 @@ public function test_core_get_environment_info_rejects_invalid_fields(): void {
* @ticket 64384
*/
public function test_core_abilities_schemas_use_only_valid_keywords(): void {
$allowed_keywords = rest_get_allowed_schema_keywords();
// Add 'required' which is valid at the property level for draft-04.
$allowed_keywords[] = 'required';
$allowed_keywords = wp_get_json_schema_allowed_keywords( 'draft-04' );

$abilities = wp_get_abilities();

Expand Down
57 changes: 57 additions & 0 deletions tests/phpunit/tests/json-schema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
/**
* JSON Schema functions.
*
* @package WordPress
* @subpackage JSON_Schema
*/

/**
* @group json-schema
*/
class Tests_JSON_Schema extends WP_UnitTestCase {

/**
* @ticket 64955
*/
public function test_wp_get_json_schema_allowed_keywords_uses_rest_keywords_by_default() {
$this->assertSame( rest_get_allowed_schema_keywords(), wp_get_json_schema_allowed_keywords() );
$this->assertSame( rest_get_allowed_schema_keywords(), wp_get_json_schema_allowed_keywords( 'rest-api' ) );
$this->assertSame( rest_get_allowed_schema_keywords(), wp_get_json_schema_allowed_keywords( 'unknown-context' ) );
}

/**
* @ticket 64955
*/
public function test_wp_get_json_schema_allowed_keywords_includes_draft_04_keywords() {
$keywords = wp_get_json_schema_allowed_keywords( 'draft-04' );

// Keywords the draft-04 profile adds on top of the REST keyword set.
foreach ( array( '$schema', 'id', '$ref', 'required', 'allOf', 'not', 'definitions', 'dependencies', 'additionalItems' ) as $keyword ) {
$this->assertContains( $keyword, $keywords );
}

// 'type' is a base REST keyword, not a draft-04 addition. Checking it
// confirms the draft-04 profile is a superset that keeps the REST keywords.
$this->assertContains( 'type', $keywords );
}

/**
* @ticket 64955
*/
public function test_wp_get_json_schema_allowed_keywords_filter_receives_schema_profile() {
$schema_profiles = array();
$filter = static function ( $keywords, $schema_profile ) use ( &$schema_profiles ) {
$schema_profiles[] = $schema_profile;
$keywords[] = 'xCustomKeyword';

return $keywords;
};

add_filter( 'wp_json_schema_allowed_keywords', $filter, 10, 2 );
$keywords = wp_get_json_schema_allowed_keywords( 'draft-04' );

$this->assertContains( 'xCustomKeyword', $keywords );
$this->assertSame( array( 'draft-04' ), $schema_profiles );
}
}
41 changes: 41 additions & 0 deletions tests/phpunit/tests/rest-api/rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -2519,6 +2519,47 @@ public function test_get_data_for_route_includes_permitted_schema_keywords() {
$this->assertSameSetsWithIndex( $expected, $args['param'] );
}

/**
* @ticket 64955
*/
public function test_get_data_for_route_includes_filtered_json_schema_keywords() {
$filter = static function ( $keywords, $schema_profile ) {
if ( 'rest-api' === $schema_profile ) {
$keywords[] = 'xRestApiKeyword';
}

return $keywords;
};

add_filter( 'wp_json_schema_allowed_keywords', $filter, 10, 2 );

register_rest_route(
'test-ns/v1',
'/test',
array(
'methods' => 'POST',
'callback' => static function () {
return new WP_REST_Response( 'test' );
},
'permission_callback' => '__return_true',
'args' => array(
'param' => array(
'type' => 'string',
'xRestApiKeyword' => true,
'invalid' => true,
),
),
)
);

$response = rest_do_request( new WP_REST_Request( 'OPTIONS', '/test-ns/v1/test' ) );

$args = $response->get_data()['endpoints'][0]['args'];

$this->assertArrayHasKey( 'xRestApiKeyword', $args['param'] );
$this->assertArrayNotHasKey( 'invalid', $args['param'] );
}

/**
* @ticket 53056
*/
Expand Down