Skip to content

Commit b226691

Browse files
committed
Abilities API: Harden ability schema preparation for REST responses
Merge `normalize_schema_empty_object_defaults()` and `strip_internal_schema_keywords()` into a single recursive `prepare_schema_for_response()` helper on `WP_REST_Abilities_V1_List_Controller`. Empty object defaults now normalize to `stdClass` at every depth — not just the top level — so nested `{}` defaults serialize consistently alongside the existing internal-keyword stripping. Follow-up to [62221], [61244]. Props gziolo, westonruter. See #64955. git-svn-id: https://develop.svn.wordpress.org/trunk@62427 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 868b575 commit b226691

2 files changed

Lines changed: 97 additions & 43 deletions

File tree

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

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -188,27 +188,6 @@ public function get_item_permissions_check( $request ) {
188188
return current_user_can( 'read' );
189189
}
190190

191-
/**
192-
* Normalizes schema empty object defaults.
193-
*
194-
* Converts empty array defaults to objects when the schema type is 'object'
195-
* to ensure proper JSON serialization as {} instead of [].
196-
*
197-
* @since 6.9.0
198-
*
199-
* @param array<string, mixed> $schema The schema array.
200-
* @return array<string, mixed> The normalized schema.
201-
*/
202-
private function normalize_schema_empty_object_defaults( array $schema ): array {
203-
if ( isset( $schema['type'] ) && 'object' === $schema['type'] && isset( $schema['default'] ) ) {
204-
$default = $schema['default'];
205-
if ( is_array( $default ) && empty( $default ) ) {
206-
$schema['default'] = (object) $default;
207-
}
208-
}
209-
return $schema;
210-
}
211-
212191
/**
213192
* WordPress-internal schema keywords to strip from REST responses.
214193
*
@@ -222,19 +201,42 @@ private function normalize_schema_empty_object_defaults( array $schema ): array
222201
);
223202

224203
/**
225-
* Recursively removes WordPress-internal keywords from a schema.
204+
* Determines whether the value is an associative array.
205+
*
206+
* @since 7.1.0
207+
*
208+
* @param mixed $value Value.
209+
* @return bool Whether it is associative array.
210+
*
211+
* @phpstan-assert-if-true array<string, mixed> $value
212+
*/
213+
private function is_associative_array( $value ): bool {
214+
return is_array( $value ) && ! wp_is_numeric_array( $value );
215+
}
216+
217+
/**
218+
* Transforms an ability schema for REST response output.
226219
*
227220
* Ability schemas may include WordPress-internal properties like
228221
* `sanitize_callback`, `validate_callback`, and `arg_options` that are
229222
* used server-side but are not valid JSON Schema keywords. This method
230223
* removes those specific keys so they are not exposed in REST responses.
224+
* It also converts empty array defaults to objects when the schema type is
225+
* 'object' to ensure proper JSON serialization as {} instead of [].
231226
*
232-
* @since 7.0.0
227+
* @since 7.1.0
233228
*
234229
* @param array<string, mixed> $schema The schema array.
235-
* @return array<string, mixed> The schema without WordPress-internal keywords.
230+
* @return array<string, mixed> The transformed schema.
236231
*/
237-
private function strip_internal_schema_keywords( array $schema ): array {
232+
private function prepare_schema_for_response( array $schema ): array {
233+
if ( isset( $schema['type'] ) && 'object' === $schema['type'] && isset( $schema['default'] ) ) {
234+
$default = $schema['default'];
235+
if ( is_array( $default ) && empty( $default ) ) {
236+
$schema['default'] = (object) $default;
237+
}
238+
}
239+
238240
$schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS );
239241

240242
// Sub-schema maps: keys are user-defined, values are sub-schemas.
@@ -243,39 +245,39 @@ private function strip_internal_schema_keywords( array $schema ): array {
243245
foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) {
244246
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
245247
foreach ( $schema[ $keyword ] as $key => $child_schema ) {
246-
if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) {
247-
$schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema );
248+
if ( $this->is_associative_array( $child_schema ) ) {
249+
$schema[ $keyword ][ $key ] = $this->prepare_schema_for_response( $child_schema );
248250
}
249251
}
250252
}
251253
}
252254

253255
// Single sub-schema keywords.
254256
foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) {
255-
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
256-
$schema[ $keyword ] = $this->strip_internal_schema_keywords( $schema[ $keyword ] );
257+
if ( isset( $schema[ $keyword ] ) && $this->is_associative_array( $schema[ $keyword ] ) ) {
258+
$schema[ $keyword ] = $this->prepare_schema_for_response( $schema[ $keyword ] );
257259
}
258260
}
259261

260262
// Items: single schema or tuple array of schemas.
261-
if ( isset( $schema['items'] ) ) {
262-
if ( wp_is_numeric_array( $schema['items'] ) ) {
263+
if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
264+
if ( $this->is_associative_array( $schema['items'] ) ) {
265+
$schema['items'] = $this->prepare_schema_for_response( $schema['items'] );
266+
} else {
263267
foreach ( $schema['items'] as $index => $item_schema ) {
264-
if ( is_array( $item_schema ) ) {
265-
$schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema );
268+
if ( $this->is_associative_array( $item_schema ) ) {
269+
$schema['items'][ $index ] = $this->prepare_schema_for_response( $item_schema );
266270
}
267271
}
268-
} elseif ( is_array( $schema['items'] ) ) {
269-
$schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] );
270272
}
271273
}
272274

273275
// Array-of-schemas keywords.
274276
foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
275277
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
276278
foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
277-
if ( is_array( $sub_schema ) ) {
278-
$schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema );
279+
if ( $this->is_associative_array( $sub_schema ) ) {
280+
$schema[ $keyword ][ $index ] = $this->prepare_schema_for_response( $sub_schema );
279281
}
280282
}
281283
}
@@ -299,12 +301,8 @@ public function prepare_item_for_response( $ability, $request ) {
299301
'label' => $ability->get_label(),
300302
'description' => $ability->get_description(),
301303
'category' => $ability->get_category(),
302-
'input_schema' => $this->strip_internal_schema_keywords(
303-
$this->normalize_schema_empty_object_defaults( $ability->get_input_schema() )
304-
),
305-
'output_schema' => $this->strip_internal_schema_keywords(
306-
$this->normalize_schema_empty_object_defaults( $ability->get_output_schema() )
307-
),
304+
'input_schema' => $this->prepare_schema_for_response( $ability->get_input_schema() ),
305+
'output_schema' => $this->prepare_schema_for_response( $ability->get_output_schema() ),
308306
'meta' => $ability->get_meta(),
309307
);
310308

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,62 @@ public function test_internal_schema_keywords_stripped_from_response(): void {
890890
$this->assertSame( 'string', $data['output_schema']['type'] );
891891
}
892892

893+
/**
894+
* Test that nested empty object defaults are prepared as objects in REST response schemas.
895+
*
896+
* @ticket 64955
897+
*/
898+
public function test_nested_empty_object_schema_defaults_prepared_for_response(): void {
899+
$this->register_test_ability(
900+
'test/nested-object-defaults',
901+
array(
902+
'label' => 'Test Nested Object Defaults',
903+
'description' => 'Tests preparing nested empty object defaults.',
904+
'category' => 'general',
905+
'input_schema' => array(
906+
'type' => 'object',
907+
'properties' => array(
908+
'settings' => array(
909+
'type' => 'object',
910+
'default' => array(),
911+
'properties' => array(
912+
'options' => array(
913+
'type' => 'object',
914+
'default' => array(),
915+
),
916+
),
917+
),
918+
),
919+
),
920+
'output_schema' => array(
921+
'type' => 'object',
922+
'properties' => array(
923+
'result' => array(
924+
'type' => 'object',
925+
'default' => array(),
926+
),
927+
),
928+
),
929+
'execute_callback' => static function (): array {
930+
return array();
931+
},
932+
'permission_callback' => '__return_true',
933+
'meta' => array( 'show_in_rest' => true ),
934+
)
935+
);
936+
937+
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-object-defaults' );
938+
$response = $this->server->dispatch( $request );
939+
940+
$this->assertSame( 200, $response->get_status() );
941+
942+
$data = $response->get_data();
943+
944+
$this->assertEquals( new stdClass(), $data['input_schema']['properties']['settings']['default'] );
945+
$this->assertEquals( new stdClass(), $data['input_schema']['properties']['settings']['properties']['options']['default'] );
946+
$this->assertEquals( new stdClass(), $data['output_schema']['properties']['result']['default'] );
947+
}
948+
893949
/**
894950
* Test that internal schema keywords are stripped from nested sub-schema locations.
895951
*

0 commit comments

Comments
 (0)