Skip to content
Closed
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
74 changes: 74 additions & 0 deletions src/wp-includes/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3227,6 +3227,80 @@ function rest_default_additional_properties_to_false( $schema ) {
return $schema;
}

/**
* Recursively casts empty arrays to objects where the schema types them as objects.
*
* PHP cannot distinguish an empty associative array from an empty list, so
* `json_encode()` always serializes `array()` as a JSON array (`[]`). A REST
* schema, however, may type a value as an object, which must encode as `{}`.
* This walks the value against its schema and casts any empty, object-typed
* array to an object. Non-empty associative arrays already encode as objects,
* so they are left as arrays and only recursed into to fix any nested empty
* objects.
*
* Union schemas (`oneOf`/`anyOf`) are handled only for the empty-array case:
* an empty value is cast to an object when any branch allows an object. Such
* values are not recursed into.
*
* @since 7.2.0
* @access private
*
* @param mixed $value The value to normalize.
* @param array $schema The schema node describing the value.
* @return mixed The normalized value, with empty object-typed arrays cast to objects.
*/
function _rest_cast_empty_objects_from_schema( $value, $schema ) {

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.

This utility is now used by the view config endpoint and it's going to be used by the fields and actions endpoints as well (landing in the next hours). I thought I'd extract it first.

if ( ! is_array( $value ) || ! is_array( $schema ) ) {
return $value;
}

if ( isset( $schema['oneOf'] ) || isset( $schema['anyOf'] ) ) {
$branches = $schema['oneOf'] ?? $schema['anyOf'];
if ( array() === $value ) {
foreach ( $branches as $branch ) {
if ( is_array( $branch ) && in_array( 'object', (array) ( $branch['type'] ?? array() ), true ) ) {
return (object) array();
}
}
}
return $value;
}

$types = (array) ( $schema['type'] ?? array() );

if ( in_array( 'array', $types, true ) && isset( $schema['items'] ) ) {
foreach ( $value as $index => $item ) {
$value[ $index ] = _rest_cast_empty_objects_from_schema( $item, $schema['items'] );
}
return $value;
}

if ( in_array( 'object', $types, true ) ) {
if ( isset( $schema['properties'] ) ) {
foreach ( $schema['properties'] as $property => $property_schema ) {
if ( array_key_exists( $property, $value ) ) {
$value[ $property ] = _rest_cast_empty_objects_from_schema( $value[ $property ], $property_schema );
}
}
}
if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
foreach ( $value as $key => $item ) {
if ( isset( $schema['properties'][ $key ] ) ) {
continue;
}
$value[ $key ] = _rest_cast_empty_objects_from_schema( $item, $schema['additionalProperties'] );
}
}

// Empty object-typed arrays must serialize as {} to match the schema.
if ( array() === $value ) {
return (object) array();
}
}

return $value;
}

/**
* Gets the REST API route for a post.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@ public function get_items( $request ) {
'kind' => $kind,
'name' => $name,
'version' => WP_View_Config_Data::LATEST_VERSION,
'default_view' => $this->cast_empty_objects( $config['default_view'], $schema['properties']['default_view'] ),
'default_layouts' => $this->cast_empty_objects( $config['default_layouts'], $schema['properties']['default_layouts'] ),
'view_list' => $this->cast_empty_objects( $config['view_list'], $schema['properties']['view_list'] ),
'form' => $this->cast_empty_objects( $config['form'], $schema['properties']['form'] ),
'default_view' => _rest_cast_empty_objects_from_schema( $config['default_view'], $schema['properties']['default_view'] ),
'default_layouts' => _rest_cast_empty_objects_from_schema( $config['default_layouts'], $schema['properties']['default_layouts'] ),
'view_list' => _rest_cast_empty_objects_from_schema( $config['view_list'], $schema['properties']['view_list'] ),
'form' => _rest_cast_empty_objects_from_schema( $config['form'], $schema['properties']['form'] ),
);

return rest_ensure_response( $response );
Expand All @@ -168,75 +168,17 @@ public function get_items( $request ) {
* Recursively casts empty arrays to objects where the schema types them as
* objects.
*
* PHP cannot distinguish an empty associative array from an empty list, so
* `json_encode()` always serializes `array()` as a JSON array (`[]`). The
* REST schema, however, types several values as objects, which must encode
* as `{}`. This walks the value against its schema and casts any empty,
* object-typed array to an object. Non-empty associative arrays already
* encode as objects, so they are left as arrays and only recursed into to
* fix any nested empty objects.
*
* Union schemas (`oneOf`/`anyOf`) are handled only for the empty-array case:
* an empty value is cast to an object when any branch allows an object. Such
* values are not recursed into, which is sufficient for the form schema
* where they never contain empty nested objects.
*
* @since 7.1.0
* @since 7.2.0 Moved the logic to _rest_cast_empty_objects_from_schema().
*
* @see _rest_cast_empty_objects_from_schema()
*
* @param mixed $value The value to normalize.
* @param array $schema The schema node describing the value.
* @return mixed The normalized value, with empty object-typed arrays cast to objects.
*/
protected function cast_empty_objects( $value, $schema ) {
if ( ! is_array( $value ) || ! is_array( $schema ) ) {
return $value;
}

if ( isset( $schema['oneOf'] ) || isset( $schema['anyOf'] ) ) {
$branches = $schema['oneOf'] ?? $schema['anyOf'];
if ( array() === $value ) {
foreach ( $branches as $branch ) {
if ( is_array( $branch ) && in_array( 'object', (array) ( $branch['type'] ?? array() ), true ) ) {
return (object) array();
}
}
}
return $value;
}

$types = (array) ( $schema['type'] ?? array() );

if ( in_array( 'array', $types, true ) && isset( $schema['items'] ) ) {
foreach ( $value as $index => $item ) {
$value[ $index ] = $this->cast_empty_objects( $item, $schema['items'] );
}
return $value;
}

if ( in_array( 'object', $types, true ) ) {
if ( isset( $schema['properties'] ) ) {
foreach ( $schema['properties'] as $property => $property_schema ) {
if ( array_key_exists( $property, $value ) ) {
$value[ $property ] = $this->cast_empty_objects( $value[ $property ], $property_schema );
}
}
}
if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
foreach ( $value as $key => $item ) {
if ( isset( $schema['properties'][ $key ] ) ) {
continue;
}
$value[ $key ] = $this->cast_empty_objects( $item, $schema['additionalProperties'] );
}
}

// Empty object-typed arrays must serialize as {} to match the schema.
if ( array() === $value ) {
return (object) array();
}
}

return $value;
return _rest_cast_empty_objects_from_schema( $value, $schema );
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/wp-includes/view-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ function _wp_get_entity_view_config_posttype_wp_block( $data ) {
'styles' => array(
'author' => array(
'width' => '1%',
'align' => 'start',

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.

),
),
),
Expand Down
163 changes: 163 additions & 0 deletions tests/phpunit/tests/rest-api/restCastEmptyObjectsFromSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

/**
* Tests for the `_rest_cast_empty_objects_from_schema()` function.
*
* @group restapi
* @covers ::_rest_cast_empty_objects_from_schema
*/
class Tests_REST_API_RestCastEmptyObjectsFromSchema extends WP_UnitTestCase {

/**
* Casts the value and encodes it, since `{}` versus `[]` is only visible
* once serialized.
*
* @param mixed $value The value to normalize.
* @param array $schema The schema node describing the value.
* @return string The JSON encoded, normalized value.
*/
private function cast_and_encode( $value, $schema ) {
return wp_json_encode( _rest_cast_empty_objects_from_schema( $value, $schema ) );
}

public function test_empty_object_encodes_as_object() {
$this->assertSame( '{}', $this->cast_and_encode( array(), array( 'type' => 'object' ) ) );
}

public function test_empty_array_encodes_as_array() {
$this->assertSame( '[]', $this->cast_and_encode( array(), array( 'type' => 'array' ) ) );
}

public function test_empty_value_with_object_in_type_list_encodes_as_object() {
$this->assertSame( '{}', $this->cast_and_encode( array(), array( 'type' => array( 'object', 'boolean' ) ) ) );
}

public function test_non_array_values_are_returned_unchanged() {
$this->assertFalse( _rest_cast_empty_objects_from_schema( false, array( 'type' => array( 'object', 'boolean' ) ) ) );
$this->assertSame( 'text', _rest_cast_empty_objects_from_schema( 'text', array( 'type' => 'string' ) ) );
}

public function test_value_without_schema_is_returned_unchanged() {
$this->assertSame( array(), _rest_cast_empty_objects_from_schema( array(), array() ) );
}

public function test_nested_properties_are_cast() {
$schema = array(
'type' => 'object',
'properties' => array(
'layout' => array(
'type' => 'object',
'properties' => array(
'styles' => array( 'type' => 'object' ),
),
),
'fields' => array( 'type' => 'array' ),
),
);

$this->assertSame(
'{"layout":{"styles":{}},"fields":[]}',
$this->cast_and_encode(
array(
'layout' => array( 'styles' => array() ),
'fields' => array(),
),
$schema
)
);
}

public function test_object_emptied_of_known_properties_is_still_cast() {
$schema = array(
'type' => 'object',
'properties' => array(
'layout' => array( 'type' => 'object' ),
),
);

$this->assertSame( '{}', $this->cast_and_encode( array(), $schema ) );
}

public function test_array_items_are_cast() {
$schema = array(
'type' => 'array',
'items' => array( 'type' => 'object' ),
);

$this->assertSame( '[{},{}]', $this->cast_and_encode( array( array(), array() ), $schema ) );
}

public function test_additional_properties_are_cast() {
$schema = array(
'type' => 'object',
'properties' => array(
'known' => array( 'type' => 'array' ),
),
'additionalProperties' => array( 'type' => 'object' ),
);

$this->assertSame(
'{"known":[],"title":{}}',
$this->cast_and_encode(
array(
'known' => array(),
'title' => array(),
),
$schema
)
);
}

public function test_boolean_additional_properties_are_left_alone() {
$schema = array(
'type' => 'object',
'additionalProperties' => true,
);

$this->assertSame( '{"extra":[]}', $this->cast_and_encode( array( 'extra' => array() ), $schema ) );
}

/**
* @dataProvider data_union_keywords
*
* @param string $keyword The union keyword, `oneOf` or `anyOf`.
*/
public function test_empty_value_is_cast_when_a_union_branch_allows_an_object( $keyword ) {
$schema = array(
$keyword => array(
array( 'type' => 'string' ),
array( 'type' => 'object' ),
),
);

$this->assertSame( '{}', $this->cast_and_encode( array(), $schema ) );
}

/**
* @dataProvider data_union_keywords
*
* @param string $keyword The union keyword, `oneOf` or `anyOf`.
*/
public function test_empty_value_is_not_cast_when_no_union_branch_allows_an_object( $keyword ) {
$schema = array(
$keyword => array(
array( 'type' => 'string' ),
array( 'type' => 'array' ),
),
);

$this->assertSame( '[]', $this->cast_and_encode( array(), $schema ) );
}

/**
* Data provider.
*
* @return array[]
*/
public function data_union_keywords() {
return array(
'oneOf' => array( 'oneOf' ),
'anyOf' => array( 'anyOf' ),
);
}
}
Loading