REST API: Add support for the uniqueItems keyword.

Props sorenbronsted.
Fixes #48821.


git-svn-id: https://develop.svn.wordpress.org/trunk@48357 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Timothy Jacobs 2020-07-07 03:20:34 +00:00
parent abfd9441b7
commit 41912bcece
4 changed files with 615 additions and 17 deletions

View File

@ -1438,6 +1438,63 @@ function rest_handle_multi_type_schema( $value, $args, $param = '' ) {
return $best_type;
}
/**
* Checks if an array is made up of unique items.
*
* @since 5.5.0
*
* @param array $array The array to check.
* @return bool True if the array contains unique items, false otherwise.
*/
function rest_validate_array_contains_unique_items( $array ) {
$seen = array();
foreach ( $array as $item ) {
$stabilized = rest_stabilize_value( $item );
$key = serialize( $stabilized );
if ( ! isset( $seen[ $key ] ) ) {
$seen[ $key ] = true;
continue;
}
return false;
}
return true;
}
/**
* Stabilizes a value following JSON Schema semantics.
*
* For lists, order is preserved. For objects, properties are reordered alphabetically.
*
* @since 5.5.0
*
* @param mixed $value The value to stabilize. Must already be sanitized. Objects should have been converted to arrays.
* @return mixed The stabilized value.
*/
function rest_stabilize_value( $value ) {
if ( is_scalar( $value ) || is_null( $value ) ) {
return $value;
}
if ( is_object( $value ) ) {
_doing_it_wrong( __FUNCTION__, __( 'Cannot stabilize objects. Convert the object to an array first.' ), '5.5.0' );
return $value;
}
ksort( $value );
foreach ( $value as $k => $v ) {
$value[ $k ] = rest_stabilize_value( $v );
}
return $value;
}
/**
* Validate a value based on a schema.
*
@ -1448,8 +1505,8 @@ function rest_handle_multi_type_schema( $value, $args, $param = '' ) {
* @since 5.4.0 Convert an empty string to an empty object.
* @since 5.5.0 Add the "uuid" and "hex-color" formats.
* Support the "minLength", "maxLength" and "pattern" keywords for strings.
* Support the "minItems", "maxItems" and "uniqueItems" keywords for arrays.
* Validate required properties.
* Support the "minItems" and "maxItems" keywords for arrays.
*
* @param mixed $value The value to validate.
* @param array $args Schema array to use for validation.
@ -1492,10 +1549,12 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) {
$value = rest_sanitize_array( $value );
foreach ( $value as $index => $v ) {
$is_valid = rest_validate_value_from_schema( $v, $args['items'], $param . '[' . $index . ']' );
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
if ( isset( $args['items'] ) ) {
foreach ( $value as $index => $v ) {
$is_valid = rest_validate_value_from_schema( $v, $args['items'], $param . '[' . $index . ']' );
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
}
}
}
@ -1508,6 +1567,11 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) {
/* translators: 1: Parameter, 2: Number. */
return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must contain at most %2$s items.' ), $param, number_format_i18n( $args['maxItems'] ) ) );
}
if ( ! empty( $args['uniqueItems'] ) && ! rest_validate_array_contains_unique_items( $value ) ) {
/* translators: 1: Parameter */
return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s has duplicate items.' ), $param ) );
}
}
if ( 'object' === $args['type'] ) {
@ -1718,7 +1782,7 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) {
* @param mixed $value The value to sanitize.
* @param array $args Schema array to use for sanitization.
* @param string $param The parameter name, used in error messages.
* @return mixed The sanitized value.
* @return mixed|WP_Error The sanitized value or a WP_Error instance if the value cannot be safely sanitized.
*/
function rest_sanitize_value_from_schema( $value, $args, $param = '' ) {
$allowed_types = array( 'array', 'object', 'string', 'number', 'integer', 'boolean', 'null' );
@ -1750,12 +1814,15 @@ function rest_sanitize_value_from_schema( $value, $args, $param = '' ) {
if ( 'array' === $args['type'] ) {
$value = rest_sanitize_array( $value );
if ( empty( $args['items'] ) ) {
return $value;
if ( ! empty( $args['items'] ) ) {
foreach ( $value as $index => $v ) {
$value[ $index ] = rest_sanitize_value_from_schema( $v, $args['items'], $param . '[' . $index . ']' );
}
}
foreach ( $value as $index => $v ) {
$value[ $index ] = rest_sanitize_value_from_schema( $v, $args['items'], $param . '[' . $index . ']' );
if ( ! empty( $args['uniqueItems'] ) && ! rest_validate_array_contains_unique_items( $value ) ) {
/* translators: 1: Parameter */
return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s has duplicate items.' ), $param ) );
}
return $value;

View File

@ -0,0 +1,374 @@
[
{
"description": "uniqueItems validation",
"schema": {"uniqueItems": true},
"tests": [
{
"description": "unique array of integers is valid",
"data": [1, 2],
"valid": true
},
{
"description": "non-unique array of integers is invalid",
"data": [1, 1],
"valid": false
},
{
"description": "numbers are unique if mathematically unequal",
"data": [1.0, 1.00, 1],
"valid": false
},
{
"description": "false is not equal to zero",
"data": [0, false],
"valid": true
},
{
"description": "true is not equal to one",
"data": [1, true],
"valid": true
},
{
"description": "unique array of objects is valid",
"data": [{"foo": "bar"}, {"foo": "baz"}],
"valid": true
},
{
"description": "non-unique array of objects is invalid",
"data": [{"foo": "bar"}, {"foo": "bar"}],
"valid": false
},
{
"description": "unique array of nested objects is valid",
"data": [
{"foo": {"bar" : {"baz" : true}}},
{"foo": {"bar" : {"baz" : false}}}
],
"valid": true
},
{
"description": "non-unique array of nested objects is invalid",
"data": [
{"foo": {"bar" : {"baz" : true}}},
{"foo": {"bar" : {"baz" : true}}}
],
"valid": false
},
{
"description": "unique array of arrays is valid",
"data": [["foo"], ["bar"]],
"valid": true
},
{
"description": "non-unique array of arrays is invalid",
"data": [["foo"], ["foo"]],
"valid": false
},
{
"description": "1 and true are unique",
"data": [1, true],
"valid": true
},
{
"description": "0 and false are unique",
"data": [0, false],
"valid": true
},
{
"description": "[1] and [true] are unique",
"data": [[1], [true]],
"valid": true
},
{
"description": "[0] and [false] are unique",
"data": [[0], [false]],
"valid": true
},
{
"description": "nested [1] and [true] are unique",
"data": [[[1], "foo"], [[true], "foo"]],
"valid": true
},
{
"description": "nested [0] and [false] are unique",
"data": [[[0], "foo"], [[false], "foo"]],
"valid": true
},
{
"description": "unique heterogeneous types are valid",
"data": [{}, [1], true, null, 1, "{}"],
"valid": true
},
{
"description": "non-unique heterogeneous types are invalid",
"data": [{}, [1], true, null, {}, 1],
"valid": false
},
{
"description": "different objects are unique",
"data": [{"a": 1, "b": 2}, {"a": 2, "b": 1}],
"valid": true
},
{
"description": "objects are non-unique despite key order",
"data": [{"a": 1, "b": 2}, {"b": 2, "a": 1}],
"valid": false
}
]
},
{
"description": "uniqueItems with an array of items",
"schema": {
"items": [{"type": "boolean"}, {"type": "boolean"}],
"uniqueItems": true
},
"tests": [
{
"description": "[false, true] from items array is valid",
"data": [false, true],
"valid": true
},
{
"description": "[true, false] from items array is valid",
"data": [true, false],
"valid": true
},
{
"description": "[false, false] from items array is not valid",
"data": [false, false],
"valid": false
},
{
"description": "[true, true] from items array is not valid",
"data": [true, true],
"valid": false
},
{
"description": "unique array extended from [false, true] is valid",
"data": [false, true, "foo", "bar"],
"valid": true
},
{
"description": "unique array extended from [true, false] is valid",
"data": [true, false, "foo", "bar"],
"valid": true
},
{
"description": "non-unique array extended from [false, true] is not valid",
"data": [false, true, "foo", "foo"],
"valid": false
},
{
"description": "non-unique array extended from [true, false] is not valid",
"data": [true, false, "foo", "foo"],
"valid": false
}
]
},
{
"description": "uniqueItems with an array of items and additionalItems=false",
"schema": {
"items": [{"type": "boolean"}, {"type": "boolean"}],
"uniqueItems": true,
"additionalItems": false
},
"tests": [
{
"description": "[false, true] from items array is valid",
"data": [false, true],
"valid": true
},
{
"description": "[true, false] from items array is valid",
"data": [true, false],
"valid": true
},
{
"description": "[false, false] from items array is not valid",
"data": [false, false],
"valid": false
},
{
"description": "[true, true] from items array is not valid",
"data": [true, true],
"valid": false
},
{
"description": "extra items are invalid even if unique",
"data": [false, true, null],
"valid": false
}
]
},
{
"description": "uniqueItems=false validation",
"schema": { "uniqueItems": false },
"tests": [
{
"description": "unique array of integers is valid",
"data": [1, 2],
"valid": true
},
{
"description": "non-unique array of integers is valid",
"data": [1, 1],
"valid": true
},
{
"description": "numbers are unique if mathematically unequal",
"data": [1.0, 1.00, 1],
"valid": true
},
{
"description": "false is not equal to zero",
"data": [0, false],
"valid": true
},
{
"description": "true is not equal to one",
"data": [1, true],
"valid": true
},
{
"description": "unique array of objects is valid",
"data": [{"foo": "bar"}, {"foo": "baz"}],
"valid": true
},
{
"description": "non-unique array of objects is valid",
"data": [{"foo": "bar"}, {"foo": "bar"}],
"valid": true
},
{
"description": "unique array of nested objects is valid",
"data": [
{"foo": {"bar" : {"baz" : true}}},
{"foo": {"bar" : {"baz" : false}}}
],
"valid": true
},
{
"description": "non-unique array of nested objects is valid",
"data": [
{"foo": {"bar" : {"baz" : true}}},
{"foo": {"bar" : {"baz" : true}}}
],
"valid": true
},
{
"description": "unique array of arrays is valid",
"data": [["foo"], ["bar"]],
"valid": true
},
{
"description": "non-unique array of arrays is valid",
"data": [["foo"], ["foo"]],
"valid": true
},
{
"description": "1 and true are unique",
"data": [1, true],
"valid": true
},
{
"description": "0 and false are unique",
"data": [0, false],
"valid": true
},
{
"description": "unique heterogeneous types are valid",
"data": [{}, [1], true, null, 1],
"valid": true
},
{
"description": "non-unique heterogeneous types are valid",
"data": [{}, [1], true, null, {}, 1],
"valid": true
}
]
},
{
"description": "uniqueItems=false with an array of items",
"schema": {
"items": [{"type": "boolean"}, {"type": "boolean"}],
"uniqueItems": false
},
"tests": [
{
"description": "[false, true] from items array is valid",
"data": [false, true],
"valid": true
},
{
"description": "[true, false] from items array is valid",
"data": [true, false],
"valid": true
},
{
"description": "[false, false] from items array is valid",
"data": [false, false],
"valid": true
},
{
"description": "[true, true] from items array is valid",
"data": [true, true],
"valid": true
},
{
"description": "unique array extended from [false, true] is valid",
"data": [false, true, "foo", "bar"],
"valid": true
},
{
"description": "unique array extended from [true, false] is valid",
"data": [true, false, "foo", "bar"],
"valid": true
},
{
"description": "non-unique array extended from [false, true] is valid",
"data": [false, true, "foo", "foo"],
"valid": true
},
{
"description": "non-unique array extended from [true, false] is valid",
"data": [true, false, "foo", "foo"],
"valid": true
}
]
},
{
"description": "uniqueItems=false with an array of items and additionalItems=false",
"schema": {
"items": [{"type": "boolean"}, {"type": "boolean"}],
"uniqueItems": false,
"additionalItems": false
},
"tests": [
{
"description": "[false, true] from items array is valid",
"data": [false, true],
"valid": true
},
{
"description": "[true, false] from items array is valid",
"data": [true, false],
"valid": true
},
{
"description": "[false, false] from items array is valid",
"data": [false, false],
"valid": true
},
{
"description": "[true, true] from items array is valid",
"data": [true, true],
"valid": true
},
{
"description": "extra items are invalid even if unique",
"data": [false, true, null],
"valid": false
}
]
}
]

View File

@ -464,4 +464,26 @@ class WP_Test_REST_Schema_Sanitization extends WP_UnitTestCase {
$this->assertNull( rest_sanitize_value_from_schema( array( 'Hello!' ), $schema ) );
}
/**
* @ticket 48821
*/
public function test_unique_items_after_sanitization() {
$schema = array(
'type' => 'array',
'uniqueItems' => true,
'items' => array(
'type' => 'string',
'format' => 'uri',
),
);
$data = array(
'https://example.org/hello%20world',
'https://example.org/hello world',
);
$this->assertTrue( rest_validate_value_from_schema( $data, $schema ) );
$this->assertWPError( rest_sanitize_value_from_schema( $data, $schema ) );
}
}

View File

@ -507,7 +507,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_property
*/
@ -535,7 +535,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_property
*/
@ -577,7 +577,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_nested_property
*/
@ -610,7 +610,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_nested_property
*/
@ -669,7 +669,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_deeply_nested_property
*/
@ -709,7 +709,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_deeply_nested_property
*/
@ -749,7 +749,7 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
}
/**
* @ticket 48818
* @ticket 48818
*
* @dataProvider data_required_deeply_nested_property
*/
@ -905,4 +905,139 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase {
$this->assertWPError( rest_validate_value_from_schema( array( 1, 2, 3 ), $schema ) );
$this->assertWPError( rest_validate_value_from_schema( 'foobar', $schema ) );
}
/**
* @ticket 48821
*
* @dataProvider data_unique_items
*/
public function test_unique_items( $test, $suite ) {
$test_description = $suite['description'] . ': ' . $test['description'];
$message = $test_description . ': ' . var_export( $test['data'], true );
$valid = rest_validate_value_from_schema( $test['data'], $suite['schema'] );
if ( $test['valid'] ) {
$this->assertTrue( $valid, $message );
} else {
$this->assertWPError( $valid, $message );
}
}
public function data_unique_items() {
$all_types = array( 'object', 'array', 'null', 'number', 'integer', 'boolean', 'string' );
// the following test suites is not supported at the moment
$skip = array(
'uniqueItems with an array of items',
'uniqueItems with an array of items and additionalItems=false',
'uniqueItems=false with an array of items',
'uniqueItems=false with an array of items and additionalItems=false',
);
$suites = json_decode( file_get_contents( __DIR__ . '/json_schema_test_suite/uniqueitems.json' ), true );
$tests = array();
foreach ( $suites as $suite ) {
if ( in_array( $suite['description'], $skip, true ) ) {
continue;
}
// type is required for our implementation
if ( ! isset( $suite['schema']['type'] ) ) {
$suite['schema']['type'] = 'array';
}
// items is required for our implementation
if ( ! isset( $suite['schema']['items'] ) ) {
$suite['schema']['items'] = array(
'type' => $all_types,
'items' => array(
'type' => $all_types,
),
);
}
foreach ( $suite['tests'] as $test ) {
$tests[] = array( $test, $suite );
}
}
return $tests;
}
/**
* @ticket 48821
*/
public function test_unique_items_deep_objects() {
$schema = array(
'type' => 'array',
'uniqueItems' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'release' => array(
'type' => 'object',
'properties' => array(
'name' => array(
'type' => 'string',
),
'version' => array(
'type' => 'string',
),
),
),
),
),
);
$data = array(
array(
'release' => array(
'name' => 'Kirk',
'version' => '5.3',
),
),
array(
'release' => array(
'version' => '5.3',
'name' => 'Kirk',
),
),
);
$this->assertWPError( rest_validate_value_from_schema( $data, $schema ) );
$data[0]['release']['version'] = '5.3.0';
$this->assertTrue( rest_validate_value_from_schema( $data, $schema ) );
}
/**
* @ticket 48821
*/
public function test_unique_items_deep_arrays() {
$schema = array(
'type' => 'array',
'uniqueItems' => true,
'items' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
);
$data = array(
array(
'Kirk',
'Jaco',
),
array(
'Kirk',
'Jaco',
),
);
$this->assertWPError( rest_validate_value_from_schema( $data, $schema ) );
$data[1] = array_reverse( $data[1] );
$this->assertTrue( rest_validate_value_from_schema( $data, $schema ) );
}
}