Skip to content

Commit c37a60a

Browse files
Abilities: Strip internal schema keywords from abilities REST responses.
Remove WordPress-internal properties (`sanitize_callback`, `validate_callback`, `arg_options`) from ability `input_schema` and `output_schema` fields in REST responses. These properties are used server-side but are not valid JSON Schema keywords and cause client-side validators to fail. Props jorgefilipecosta, ocean90, gziolo. Fixes #65035. git-svn-id: https://develop.svn.wordpress.org/trunk@62221 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 502624f commit c37a60a

2 files changed

Lines changed: 321 additions & 2 deletions

File tree

src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,81 @@ private function normalize_schema_empty_object_defaults( array $schema ): array
215215
return $schema;
216216
}
217217

218+
/**
219+
* WordPress-internal schema keywords to strip from REST responses.
220+
*
221+
* @since 7.0.0
222+
* @var array<string, true>
223+
*/
224+
private const INTERNAL_SCHEMA_KEYWORDS = array(
225+
'sanitize_callback' => true,
226+
'validate_callback' => true,
227+
'arg_options' => true,
228+
);
229+
230+
/**
231+
* Recursively removes WordPress-internal keywords from a schema.
232+
*
233+
* Ability schemas may include WordPress-internal properties like
234+
* `sanitize_callback`, `validate_callback`, and `arg_options` that are
235+
* used server-side but are not valid JSON Schema keywords. This method
236+
* removes those specific keys so they are not exposed in REST responses.
237+
*
238+
* @since 7.0.0
239+
*
240+
* @param array<string, mixed> $schema The schema array.
241+
* @return array<string, mixed> The schema without WordPress-internal keywords.
242+
*/
243+
private function strip_internal_schema_keywords( array $schema ): array {
244+
$schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS );
245+
246+
// Sub-schema maps: keys are user-defined, values are sub-schemas.
247+
// Note: 'dependencies' values can also be property-dependency arrays
248+
// (numeric arrays of strings) which are skipped via wp_is_numeric_array().
249+
foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) {
250+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
251+
foreach ( $schema[ $keyword ] as $key => $child_schema ) {
252+
if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) {
253+
$schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema );
254+
}
255+
}
256+
}
257+
}
258+
259+
// Single sub-schema keywords.
260+
foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) {
261+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
262+
$schema[ $keyword ] = $this->strip_internal_schema_keywords( $schema[ $keyword ] );
263+
}
264+
}
265+
266+
// Items: single schema or tuple array of schemas.
267+
if ( isset( $schema['items'] ) ) {
268+
if ( wp_is_numeric_array( $schema['items'] ) ) {
269+
foreach ( $schema['items'] as $index => $item_schema ) {
270+
if ( is_array( $item_schema ) ) {
271+
$schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema );
272+
}
273+
}
274+
} elseif ( is_array( $schema['items'] ) ) {
275+
$schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] );
276+
}
277+
}
278+
279+
// Array-of-schemas keywords.
280+
foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
281+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
282+
foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
283+
if ( is_array( $sub_schema ) ) {
284+
$schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema );
285+
}
286+
}
287+
}
288+
}
289+
290+
return $schema;
291+
}
292+
218293
/**
219294
* Prepares an ability for response.
220295
*
@@ -230,8 +305,12 @@ public function prepare_item_for_response( $ability, $request ) {
230305
'label' => $ability->get_label(),
231306
'description' => $ability->get_description(),
232307
'category' => $ability->get_category(),
233-
'input_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_input_schema() ),
234-
'output_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_output_schema() ),
308+
'input_schema' => $this->strip_internal_schema_keywords(
309+
$this->normalize_schema_empty_object_defaults( $ability->get_input_schema() )
310+
),
311+
'output_schema' => $this->strip_internal_schema_keywords(
312+
$this->normalize_schema_empty_object_defaults( $ability->get_output_schema() )
313+
),
235314
'meta' => $ability->get_meta(),
236315
);
237316

tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,4 +776,244 @@ public function test_filter_by_nonexistent_category(): void {
776776
$this->assertIsArray( $data );
777777
$this->assertEmpty( $data, 'Should return empty array for non-existent category' );
778778
}
779+
780+
/**
781+
* Test that WordPress-internal schema keywords are stripped from ability schemas in REST response.
782+
*
783+
* @ticket 65035
784+
*/
785+
public function test_internal_schema_keywords_stripped_from_response(): void {
786+
$this->register_test_ability(
787+
'test/with-internal-keywords',
788+
array(
789+
'label' => 'Test Internal Keywords',
790+
'description' => 'Tests stripping of internal schema keywords',
791+
'category' => 'general',
792+
'input_schema' => array(
793+
'type' => 'object',
794+
'properties' => array(
795+
'content' => array(
796+
'type' => 'string',
797+
'description' => 'The content value.',
798+
'sanitize_callback' => 'sanitize_text_field',
799+
'validate_callback' => 'is_string',
800+
'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ),
801+
),
802+
),
803+
),
804+
'output_schema' => array(
805+
'type' => 'string',
806+
'sanitize_callback' => 'sanitize_text_field',
807+
),
808+
'execute_callback' => static function ( $input ) {
809+
return $input['content'];
810+
},
811+
'permission_callback' => '__return_true',
812+
'meta' => array( 'show_in_rest' => true ),
813+
)
814+
);
815+
816+
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' );
817+
$response = $this->server->dispatch( $request );
818+
819+
$this->assertSame( 200, $response->get_status() );
820+
821+
$data = $response->get_data();
822+
$this->assertArrayHasKey( 'input_schema', $data );
823+
$this->assertArrayHasKey( 'properties', $data['input_schema'] );
824+
$this->assertArrayHasKey( 'content', $data['input_schema']['properties'] );
825+
$this->assertArrayHasKey( 'output_schema', $data );
826+
827+
// Verify internal keywords are stripped from input_schema properties.
828+
$content_schema = $data['input_schema']['properties']['content'];
829+
$this->assertArrayNotHasKey( 'sanitize_callback', $content_schema );
830+
$this->assertArrayNotHasKey( 'validate_callback', $content_schema );
831+
$this->assertArrayNotHasKey( 'arg_options', $content_schema );
832+
833+
// Verify valid JSON Schema keywords are preserved.
834+
$this->assertSame( 'string', $content_schema['type'] );
835+
$this->assertSame( 'The content value.', $content_schema['description'] );
836+
837+
// Verify internal keywords are stripped from output_schema.
838+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] );
839+
$this->assertSame( 'string', $data['output_schema']['type'] );
840+
}
841+
842+
/**
843+
* Test that internal schema keywords are stripped from nested sub-schema locations.
844+
*
845+
* @ticket 64098
846+
*/
847+
public function test_internal_schema_keywords_stripped_from_nested_sub_schemas(): void {
848+
$this->register_test_ability(
849+
'test/nested-internal-keywords',
850+
array(
851+
'label' => 'Test Nested Keywords',
852+
'description' => 'Tests stripping from all sub-schema locations',
853+
'category' => 'general',
854+
'input_schema' => array(
855+
'type' => 'object',
856+
'anyOf' => array(
857+
array(
858+
'type' => 'object',
859+
'sanitize_callback' => 'sanitize_text_field',
860+
'properties' => array(
861+
'value' => array(
862+
'type' => 'string',
863+
'validate_callback' => 'is_string',
864+
),
865+
),
866+
),
867+
array(
868+
'type' => 'number',
869+
'arg_options' => array( 'sanitize_callback' => 'absint' ),
870+
),
871+
),
872+
'oneOf' => array(
873+
array(
874+
'type' => 'string',
875+
'sanitize_callback' => 'sanitize_text_field',
876+
),
877+
),
878+
'allOf' => array(
879+
array(
880+
'type' => 'object',
881+
'validate_callback' => 'rest_validate_request_arg',
882+
),
883+
),
884+
'not' => array(
885+
'type' => 'null',
886+
'arg_options' => array( 'sanitize_callback' => 'absint' ),
887+
),
888+
'patternProperties' => array(
889+
'^S_' => array(
890+
'type' => 'string',
891+
'sanitize_callback' => 'sanitize_text_field',
892+
),
893+
),
894+
'definitions' => array(
895+
'address' => array(
896+
'type' => 'object',
897+
'validate_callback' => 'rest_validate_request_arg',
898+
'properties' => array(
899+
'street' => array(
900+
'type' => 'string',
901+
'sanitize_callback' => 'sanitize_text_field',
902+
),
903+
),
904+
),
905+
),
906+
'dependencies' => array(
907+
'bar' => array(
908+
'type' => 'object',
909+
'validate_callback' => 'rest_validate_request_arg',
910+
'properties' => array(
911+
'baz' => array(
912+
'type' => 'string',
913+
'sanitize_callback' => 'sanitize_text_field',
914+
),
915+
),
916+
),
917+
'qux' => array( 'bar' ),
918+
),
919+
'additionalProperties' => array(
920+
'type' => 'string',
921+
'sanitize_callback' => 'sanitize_text_field',
922+
),
923+
),
924+
'output_schema' => array(
925+
'type' => 'array',
926+
'items' => array(
927+
array(
928+
'type' => 'string',
929+
'validate_callback' => 'is_string',
930+
),
931+
array(
932+
'type' => 'number',
933+
'arg_options' => array( 'sanitize_callback' => 'absint' ),
934+
),
935+
),
936+
'additionalItems' => array(
937+
'type' => 'boolean',
938+
'sanitize_callback' => 'rest_sanitize_boolean',
939+
),
940+
),
941+
'execute_callback' => static function ( $input ) {
942+
return array();
943+
},
944+
'permission_callback' => '__return_true',
945+
'meta' => array( 'show_in_rest' => true ),
946+
)
947+
);
948+
949+
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-internal-keywords' );
950+
$response = $this->server->dispatch( $request );
951+
952+
$this->assertSame( 200, $response->get_status() );
953+
954+
$data = $response->get_data();
955+
956+
// Verify internal keywords are stripped from anyOf sub-schemas.
957+
$this->assertArrayHasKey( 'anyOf', $data['input_schema'] );
958+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['anyOf'][0] );
959+
$this->assertSame( 'object', $data['input_schema']['anyOf'][0]['type'] );
960+
$this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['anyOf'][0]['properties']['value'] );
961+
$this->assertSame( 'string', $data['input_schema']['anyOf'][0]['properties']['value']['type'] );
962+
$this->assertArrayNotHasKey( 'arg_options', $data['input_schema']['anyOf'][1] );
963+
$this->assertSame( 'number', $data['input_schema']['anyOf'][1]['type'] );
964+
965+
// Verify internal keywords are stripped from oneOf sub-schemas.
966+
$this->assertArrayHasKey( 'oneOf', $data['input_schema'] );
967+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['oneOf'][0] );
968+
$this->assertSame( 'string', $data['input_schema']['oneOf'][0]['type'] );
969+
970+
// Verify internal keywords are stripped from allOf sub-schemas.
971+
$this->assertArrayHasKey( 'allOf', $data['input_schema'] );
972+
$this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['allOf'][0] );
973+
$this->assertSame( 'object', $data['input_schema']['allOf'][0]['type'] );
974+
975+
// Verify internal keywords are stripped from not sub-schema.
976+
$this->assertArrayHasKey( 'not', $data['input_schema'] );
977+
$this->assertArrayNotHasKey( 'arg_options', $data['input_schema']['not'] );
978+
$this->assertSame( 'null', $data['input_schema']['not']['type'] );
979+
980+
// Verify internal keywords are stripped from patternProperties sub-schemas.
981+
$this->assertArrayHasKey( 'patternProperties', $data['input_schema'] );
982+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['patternProperties']['^S_'] );
983+
$this->assertSame( 'string', $data['input_schema']['patternProperties']['^S_']['type'] );
984+
985+
// Verify internal keywords are stripped from dependencies schema values.
986+
$this->assertArrayHasKey( 'dependencies', $data['input_schema'] );
987+
$this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['dependencies']['bar'] );
988+
$this->assertSame( 'object', $data['input_schema']['dependencies']['bar']['type'] );
989+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['dependencies']['bar']['properties']['baz'] );
990+
$this->assertSame( 'string', $data['input_schema']['dependencies']['bar']['properties']['baz']['type'] );
991+
// Property dependencies (numeric arrays) should pass through unchanged.
992+
$this->assertSame( array( 'bar' ), $data['input_schema']['dependencies']['qux'] );
993+
994+
// Verify internal keywords are stripped from definitions sub-schemas.
995+
$this->assertArrayHasKey( 'definitions', $data['input_schema'] );
996+
$this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['definitions']['address'] );
997+
$this->assertSame( 'object', $data['input_schema']['definitions']['address']['type'] );
998+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['definitions']['address']['properties']['street'] );
999+
$this->assertSame( 'string', $data['input_schema']['definitions']['address']['properties']['street']['type'] );
1000+
1001+
// Verify internal keywords are stripped from additionalProperties sub-schema.
1002+
$this->assertArrayHasKey( 'additionalProperties', $data['input_schema'] );
1003+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['additionalProperties'] );
1004+
$this->assertSame( 'string', $data['input_schema']['additionalProperties']['type'] );
1005+
1006+
// Verify internal keywords are stripped from tuple-style items sub-schemas.
1007+
$this->assertArrayHasKey( 'items', $data['output_schema'] );
1008+
$this->assertCount( 2, $data['output_schema']['items'] );
1009+
$this->assertArrayNotHasKey( 'validate_callback', $data['output_schema']['items'][0] );
1010+
$this->assertSame( 'string', $data['output_schema']['items'][0]['type'] );
1011+
$this->assertArrayNotHasKey( 'arg_options', $data['output_schema']['items'][1] );
1012+
$this->assertSame( 'number', $data['output_schema']['items'][1]['type'] );
1013+
1014+
// Verify internal keywords are stripped from additionalItems sub-schema.
1015+
$this->assertArrayHasKey( 'additionalItems', $data['output_schema'] );
1016+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema']['additionalItems'] );
1017+
$this->assertSame( 'boolean', $data['output_schema']['additionalItems']['type'] );
1018+
}
7791019
}

0 commit comments

Comments
 (0)