diff --git a/src/wp-includes/json-schema.php b/src/wp-includes/json-schema.php new file mode 100644 index 0000000000000..9832a479a7d3b --- /dev/null +++ b/src/wp-includes/json-schema.php @@ -0,0 +1,72 @@ + $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..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 @@ -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 + */ + private array $allowed_schema_keyword_lookup; + /** * Registers the routes for abilities. * @@ -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. * @@ -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 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 ); + } + + return $this->allowed_schema_keyword_lookup; + } + /** * Transforms an ability schema for REST response output. * @@ -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. 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..445a03a6c1cfa --- /dev/null +++ b/tests/phpunit/tests/json-schema.php @@ -0,0 +1,57 @@ +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 ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php index 57b7bbb38abcd..f61d2fc745d99 100644 --- a/tests/phpunit/tests/rest-api/rest-server.php +++ b/tests/phpunit/tests/rest-api/rest-server.php @@ -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 */