From 9749b5f2252e6d3c715597b00a435abac6cf0b4b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 19 Jun 2026 14:39:56 +0200 Subject: [PATCH 1/4] REST API: Add a shared helper for JSON Schema allowed keywords. Introduce wp_get_json_schema_allowed_keywords() as one place to decide which JSON Schema keywords may stay in schema output. It supports two profiles: 'rest-api' for REST route output (the existing keyword set) and 'draft-04' for the larger set used when publishing schemas to clients, such as the Abilities API. REST route schema output now flows through the new helper, so it can be adjusted with the new 'wp_json_schema_allowed_keywords' filter. Validation internals keep calling rest_get_allowed_schema_keywords() directly, so validation behavior does not change. The Abilities list controller now reads its passthrough keywords from the 'draft-04' profile instead of a private constant. This is a preparatory step for the AI schema compiler work. See #64955. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/json-schema.php | 65 +++++++++++++++++++ .../rest-api/class-wp-rest-server.php | 2 +- ...s-wp-rest-abilities-v1-list-controller.php | 57 ++++++++-------- src/wp-settings.php | 1 + .../abilities-api/wpRegisterCoreAbilities.php | 4 +- tests/phpunit/tests/json-schema.php | 55 ++++++++++++++++ tests/phpunit/tests/rest-api/rest-server.php | 42 ++++++++++++ 7 files changed, 191 insertions(+), 35 deletions(-) create mode 100644 src/wp-includes/json-schema.php create mode 100644 tests/phpunit/tests/json-schema.php diff --git a/src/wp-includes/json-schema.php b/src/wp-includes/json-schema.php new file mode 100644 index 0000000000000..8f73997804e68 --- /dev/null +++ b/src/wp-includes/json-schema.php @@ -0,0 +1,65 @@ + $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 ); +} diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index f58f3aa1b0095..9655b59af3b95 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -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 ); 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 9fd251815b383..0a4151ea0b645 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 @@ -34,6 +34,16 @@ class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller { */ protected $rest_base = 'abilities'; + /** + * Allowed schema keywords for preparing ability schemas in REST responses. + * + * Computed lazily on first use and reused while preparing nested schemas. + * + * @since 7.1.0 + * @var array|null + */ + private $allowed_schema_keywords = null; + /** * Registers the routes for abilities. * @@ -188,26 +198,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. * @@ -222,6 +212,21 @@ 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. + * + * @since 7.1.0 + * + * @return array Allowed schema keywords. + */ + private function get_allowed_schema_keywords_for_response(): array { + if ( null === $this->allowed_schema_keywords ) { + $this->allowed_schema_keywords = array_fill_keys( wp_get_json_schema_allowed_keywords( 'draft-04' ), true ); + } + + return $this->allowed_schema_keywords; + } + /** * Transforms an ability schema for REST response output. * @@ -253,17 +258,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. diff --git a/src/wp-settings.php b/src/wp-settings.php index ef5c7784ee561..ea1c1a126f158 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -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'; diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index c85174406ea95..83011b727a994 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -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(); diff --git a/tests/phpunit/tests/json-schema.php b/tests/phpunit/tests/json-schema.php new file mode 100644 index 0000000000000..35d8520412f1b --- /dev/null +++ b/tests/phpunit/tests/json-schema.php @@ -0,0 +1,55 @@ +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' ); + + foreach ( array( '$schema', 'id', '$ref', 'required', 'allOf', 'not', 'definitions', 'dependencies', 'additionalItems' ) as $keyword ) { + $this->assertContains( $keyword, $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' ); + remove_filter( 'wp_json_schema_allowed_keywords', $filter, 10 ); + + $this->assertContains( 'xCustomKeyword', $keywords ); + $this->assertSame( array( 'draft-04' ), $schema_profiles ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 57b7bbb38abcd..655f168f164ea 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -2519,6 +2519,48 @@ 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' ) ); + remove_filter( 'wp_json_schema_allowed_keywords', $filter, 10 ); + + $args = $response->get_data()['endpoints'][0]['args']; + + $this->assertArrayHasKey( 'xRestApiKeyword', $args['param'] ); + $this->assertArrayNotHasKey( 'invalid', $args['param'] ); + } + /** * @ticket 53056 */ From 69d8205f4640137819708984e5797de3703c565b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 19 Jun 2026 14:49:37 +0200 Subject: [PATCH 2/4] REST API: Clarify JSON Schema keyword helper after review. Rename the Abilities controller cache to $allowed_schema_keyword_lookup to show it holds a keyword-keyed map for array_intersect_key(), not a plain list. Drop the manual remove_filter() calls in the new tests since WP_UnitTestCase restores hooks automatically. Document why the draft-04 keyword test checks 'type' separately. See #64955. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../class-wp-rest-abilities-v1-list-controller.php | 14 ++++++++------ tests/phpunit/tests/json-schema.php | 4 +++- tests/phpunit/tests/rest-api/rest-server.php | 1 - 3 files changed, 11 insertions(+), 8 deletions(-) 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 0a4151ea0b645..6fc07f23a4720 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 @@ -35,14 +35,16 @@ class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller { protected $rest_base = 'abilities'; /** - * Allowed schema keywords for preparing ability schemas in REST responses. + * Lookup map of allowed schema keywords for preparing ability schemas in REST responses. * - * Computed lazily on first use and reused while preparing nested schemas. + * 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|null */ - private $allowed_schema_keywords = null; + private $allowed_schema_keyword_lookup = null; /** * Registers the routes for abilities. @@ -220,11 +222,11 @@ private function is_associative_array( $value ): bool { * @return array Allowed schema keywords. */ private function get_allowed_schema_keywords_for_response(): array { - if ( null === $this->allowed_schema_keywords ) { - $this->allowed_schema_keywords = array_fill_keys( wp_get_json_schema_allowed_keywords( 'draft-04' ), true ); + if ( null === $this->allowed_schema_keyword_lookup ) { + $this->allowed_schema_keyword_lookup = array_fill_keys( wp_get_json_schema_allowed_keywords( 'draft-04' ), true ); } - return $this->allowed_schema_keywords; + return $this->allowed_schema_keyword_lookup; } /** diff --git a/tests/phpunit/tests/json-schema.php b/tests/phpunit/tests/json-schema.php index 35d8520412f1b..445a03a6c1cfa 100644 --- a/tests/phpunit/tests/json-schema.php +++ b/tests/phpunit/tests/json-schema.php @@ -26,10 +26,13 @@ public function test_wp_get_json_schema_allowed_keywords_uses_rest_keywords_by_d 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 ); } @@ -47,7 +50,6 @@ public function test_wp_get_json_schema_allowed_keywords_filter_receives_schema_ add_filter( 'wp_json_schema_allowed_keywords', $filter, 10, 2 ); $keywords = wp_get_json_schema_allowed_keywords( 'draft-04' ); - remove_filter( 'wp_json_schema_allowed_keywords', $filter, 10 ); $this->assertContains( 'xCustomKeyword', $keywords ); $this->assertSame( array( 'draft-04' ), $schema_profiles ); diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 655f168f164ea..f61d2fc745d99 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -2553,7 +2553,6 @@ public function test_get_data_for_route_includes_filtered_json_schema_keywords() ); $response = rest_do_request( new WP_REST_Request( 'OPTIONS', '/test-ns/v1/test' ) ); - remove_filter( 'wp_json_schema_allowed_keywords', $filter, 10 ); $args = $response->get_data()['endpoints'][0]['args']; From d99f15d89575dbe15997fda141f37645c92cbfba Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 22 Jun 2026 13:33:18 +0200 Subject: [PATCH 3/4] REST API: Type the abilities schema keyword lookup property. Drop the nullable default on $allowed_schema_keyword_lookup in favor of a typed array property, and switch the lazy-init guard to isset(). This removes null as a valid value and tightens the type, addressing review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../class-wp-rest-abilities-v1-list-controller.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 6fc07f23a4720..7f3fecf88488a 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 @@ -42,9 +42,9 @@ class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller { * preparing nested schemas. * * @since 7.1.0 - * @var array|null + * @var array */ - private $allowed_schema_keyword_lookup = null; + private array $allowed_schema_keyword_lookup; /** * Registers the routes for abilities. @@ -222,7 +222,7 @@ private function is_associative_array( $value ): bool { * @return array Allowed schema keywords. */ private function get_allowed_schema_keywords_for_response(): array { - if ( null === $this->allowed_schema_keyword_lookup ) { + if ( ! isset( $this->allowed_schema_keyword_lookup ) ) { $this->allowed_schema_keyword_lookup = array_fill_keys( wp_get_json_schema_allowed_keywords( 'draft-04' ), true ); } From 435808d127f5ca5a90091cb1ae34ab668c40de3a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 22 Jun 2026 13:40:13 +0200 Subject: [PATCH 4/4] REST API: Clarify the draft-04 schema keyword profile. Document that both keyword profiles describe JSON Schema draft-04 output and differ only in scope. The 'rest-api' profile is the subset WordPress uses for route output, while 'draft-04' is the fuller set for schemas published to clients. Add a note at the Abilities call site explaining why the fuller set is used. This addresses review feedback that the 'draft-04' profile name read as inconsistent next to 'rest-api'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/json-schema.php | 13 ++++++++++--- .../class-wp-rest-abilities-v1-list-controller.php | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/json-schema.php b/src/wp-includes/json-schema.php index 8f73997804e68..9832a479a7d3b 100644 --- a/src/wp-includes/json-schema.php +++ b/src/wp-includes/json-schema.php @@ -11,9 +11,16 @@ * 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. Pass 'rest-api' for REST API route output. Pass 'draft-04' - * for the larger set used when publishing schemas to clients, such as the - * Abilities API. + * 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. 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 7f3fecf88488a..9f6d6bca0a143 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,6 +217,11 @@ private function is_associative_array( $value ): bool { /** * 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 Allowed schema keywords.