From 41912bcecefdbf83021cdbf09bb03583fe438e6c Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Tue, 7 Jul 2020 03:20:34 +0000 Subject: [PATCH] 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 --- src/wp-includes/rest-api.php | 87 +++- .../json_schema_test_suite/uniqueitems.json | 374 ++++++++++++++++++ .../rest-api/rest-schema-sanitization.php | 22 ++ .../tests/rest-api/rest-schema-validation.php | 149 ++++++- 4 files changed, 615 insertions(+), 17 deletions(-) create mode 100644 tests/phpunit/tests/rest-api/json_schema_test_suite/uniqueitems.json diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c8435a1919..e0387500f0 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -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; diff --git a/tests/phpunit/tests/rest-api/json_schema_test_suite/uniqueitems.json b/tests/phpunit/tests/rest-api/json_schema_test_suite/uniqueitems.json new file mode 100644 index 0000000000..4c22e21ea7 --- /dev/null +++ b/tests/phpunit/tests/rest-api/json_schema_test_suite/uniqueitems.json @@ -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 + } + ] + } +] diff --git a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php index da76aaaa41..a755a22011 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php +++ b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php @@ -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 ) ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php index 4e6ea523c7..7621205504 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-validation.php +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -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 ) ); + } }