From 54aa0bc7d1d6ca69de297d14785fe7e1b497e858 Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Tue, 20 Oct 2020 18:22:39 +0000 Subject: [PATCH] REST API: Add support for the oneOf and anyOf keywords. This allows for REST API routes to define more complex validation requirements as JSON Schema instead of procedural validation. The error code returned from `rest_validate_value_from_schema` for invalid parameter types has been changed from the generic `rest_invalid_param` to the more specific `rest_invalid_type`. Props yakimun, johnbillion, TimothyBlynJacobs. Fixes #51025. git-svn-id: https://develop.svn.wordpress.org/trunk@49246 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/rest-api.php | 349 ++++++++++++++++- tests/phpunit/tests/rest-api.php | 234 ++++++++++- .../json_schema_test_suite/anyof.json | 229 +++++++++++ .../json_schema_test_suite/oneof.json | 365 ++++++++++++++++++ .../tests/rest-api/rest-controller.php | 8 +- .../tests/rest-api/rest-post-meta-fields.php | 4 +- .../rest-api/rest-schema-sanitization.php | 47 +++ .../tests/rest-api/rest-schema-validation.php | 308 +++++++++++++++ .../tests/rest-api/rest-term-meta-fields.php | 4 +- .../tests/rest-api/rest-test-controller.php | 36 ++ 10 files changed, 1546 insertions(+), 38 deletions(-) create mode 100644 tests/phpunit/tests/rest-api/json_schema_test_suite/anyof.json create mode 100644 tests/phpunit/tests/rest-api/json_schema_test_suite/oneof.json diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d0f9effd3e..099fa9a64e 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -1664,6 +1664,216 @@ function rest_find_matching_pattern_property_schema( $property, $args ) { return null; } +/** + * Formats a combining operation error into a WP_Error object. + * + * @since 5.6.0 + * + * @param string $param The parameter name. + * @param array $error The error details. + * @return WP_Error + */ +function rest_format_combining_operation_error( $param, $error ) { + $position = $error['index']; + $reason = $error['error_object']->get_error_message(); + + if ( isset( $error['schema']['title'] ) ) { + $title = $error['schema']['title']; + + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: Parameter, 2: Schema title, 3: Reason. */ + sprintf( __( '%1$s is not a valid %2$s. Reason: %3$s' ), $param, $title, $reason ), + array( 'position' => $position ) + ); + } + + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: Parameter, 2: Reason. */ + sprintf( __( '%1$s does not match the expected format. Reason: %2$s' ), $param, $reason ), + array( 'position' => $position ) + ); +} + +/** + * Gets the error of combining operation. + * + * @since 5.6.0 + * + * @param array $value The value to validate. + * @param string $param The parameter name, used in error messages. + * @param array $errors The errors array, to search for possible error. + * @return WP_Error The combining operation error. + */ +function rest_get_combining_operation_error( $value, $param, $errors ) { + // If there is only one error, simply return it. + if ( 1 === count( $errors ) ) { + return rest_format_combining_operation_error( $param, $errors[0] ); + } + + // Filter out all errors related to type validation. + $filtered_errors = array(); + foreach ( $errors as $error ) { + $error_code = $error['error_object']->get_error_code(); + $error_data = $error['error_object']->get_error_data(); + + if ( 'rest_invalid_type' !== $error_code || ( isset( $error_data['param'] ) && $param !== $error_data['param'] ) ) { + $filtered_errors[] = $error; + } + } + + // If there is only one error left, simply return it. + if ( 1 === count( $filtered_errors ) ) { + return rest_format_combining_operation_error( $param, $filtered_errors[0] ); + } + + // If there are only errors related to object validation, try choosing the most appropriate one. + if ( count( $filtered_errors ) > 1 && 'object' === $filtered_errors[0]['schema']['type'] ) { + $result = null; + $number = 0; + + foreach ( $filtered_errors as $error ) { + if ( isset( $error['schema']['properties'] ) ) { + $n = count( array_intersect_key( $error['schema']['properties'], $value ) ); + if ( $n > $number ) { + $result = $error; + $number = $n; + } + } + } + + if ( null !== $result ) { + return rest_format_combining_operation_error( $param, $result ); + } + } + + // If each schema has a title, include those titles in the error message. + $schema_titles = array(); + foreach ( $errors as $error ) { + if ( isset( $error['schema']['title'] ) ) { + $schema_titles[] = $error['schema']['title']; + } + } + + if ( count( $schema_titles ) === count( $errors ) ) { + /* translators: 1: Parameter, 2: Schema titles. */ + return new WP_Error( 'rest_invalid_param', wp_sprintf( __( '%1$s is not a valid %2$l.' ), $param, $schema_titles ) ); + } + + /* translators: 1: Parameter. */ + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s does not match any of the expected formats.' ), $param ) ); +} + +/** + * Finds the matching schema among the "anyOf" schemas. + * + * @since 5.6.0 + * + * @param mixed $value The value to validate. + * @param array $args The schema array to use. + * @param string $param The parameter name, used in error messages. + * @return array|WP_Error The matching schema or WP_Error instance if all schemas do not match. + */ +function rest_find_any_matching_schema( $value, $args, $param ) { + $errors = array(); + + foreach ( $args['anyOf'] as $index => $schema ) { + if ( ! isset( $schema['type'] ) && isset( $args['type'] ) ) { + $schema['type'] = $args['type']; + } + + $is_valid = rest_validate_value_from_schema( $value, $schema, $param ); + if ( ! is_wp_error( $is_valid ) ) { + return $schema; + } + + $errors[] = array( + 'error_object' => $is_valid, + 'schema' => $schema, + 'index' => $index, + ); + } + + return rest_get_combining_operation_error( $value, $param, $errors ); +} + +/** + * Finds the matching schema among the "oneOf" schemas. + * + * @since 5.6.0 + * + * @param mixed $value The value to validate. + * @param array $args The schema array to use. + * @param string $param The parameter name, used in error messages. + * @param bool $stop_after_first_match Optional. Whether the process should stop after the first successful match. + * @return array|WP_Error The matching schema or WP_Error instance if the number of matching schemas is not equal to one. + */ +function rest_find_one_matching_schema( $value, $args, $param, $stop_after_first_match = false ) { + $matching_schemas = array(); + $errors = array(); + + foreach ( $args['oneOf'] as $index => $schema ) { + if ( ! isset( $schema['type'] ) && isset( $args['type'] ) ) { + $schema['type'] = $args['type']; + } + + $is_valid = rest_validate_value_from_schema( $value, $schema, $param ); + if ( ! is_wp_error( $is_valid ) ) { + if ( $stop_after_first_match ) { + return $schema; + } + + $matching_schemas[] = array( + 'schema_object' => $schema, + 'index' => $index, + ); + } else { + $errors[] = array( + 'error_object' => $is_valid, + 'schema' => $schema, + 'index' => $index, + ); + } + } + + if ( ! $matching_schemas ) { + return rest_get_combining_operation_error( $value, $param, $errors ); + } + + if ( count( $matching_schemas ) > 1 ) { + $schema_positions = array(); + $schema_titles = array(); + + foreach ( $matching_schemas as $schema ) { + $schema_positions[] = $schema['index']; + + if ( isset( $schema['schema_object']['title'] ) ) { + $schema_titles[] = $schema['schema_object']['title']; + } + } + + // If each schema has a title, include those titles in the error message. + if ( count( $schema_titles ) === count( $matching_schemas ) ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: Parameter, 2: Schema titles. */ + wp_sprintf( __( '%1$s matches %2$l, but should match only one.' ), $param, $schema_titles ), + array( 'positions' => $schema_positions ) + ); + } + + return new WP_Error( + 'rest_invalid_param', + /* translators: 1: Parameter. */ + sprintf( __( '%1$s matches more than one of the expected formats.' ), $param ), + array( 'positions' => $schema_positions ) + ); + } + + return $matching_schemas[0]['schema_object']; +} + /** * Validate a value based on a schema. * @@ -1679,6 +1889,7 @@ function rest_find_matching_pattern_property_schema( $property, $args ) { * @since 5.6.0 Support the "minProperties" and "maxProperties" keywords for objects. * Support the "multipleOf" keyword for numbers and integers. * Support the "patternProperties" keyword for objects. + * Support the "anyOf" and "oneOf" keywords. * * @param mixed $value The value to validate. * @param array $args Schema array to use for validation. @@ -1686,6 +1897,28 @@ function rest_find_matching_pattern_property_schema( $property, $args ) { * @return true|WP_Error */ function rest_validate_value_from_schema( $value, $args, $param = '' ) { + if ( isset( $args['anyOf'] ) ) { + $matching_schema = rest_find_any_matching_schema( $value, $args, $param ); + if ( is_wp_error( $matching_schema ) ) { + return $matching_schema; + } + + if ( ! isset( $args['type'] ) && isset( $matching_schema['type'] ) ) { + $args['type'] = $matching_schema['type']; + } + } + + if ( isset( $args['oneOf'] ) ) { + $matching_schema = rest_find_one_matching_schema( $value, $args, $param ); + if ( is_wp_error( $matching_schema ) ) { + return $matching_schema; + } + + if ( ! isset( $args['type'] ) && isset( $matching_schema['type'] ) ) { + $args['type'] = $matching_schema['type']; + } + } + $allowed_types = array( 'array', 'object', 'string', 'number', 'integer', 'boolean', 'null' ); if ( ! isset( $args['type'] ) ) { @@ -1697,8 +1930,12 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { $best_type = rest_handle_multi_type_schema( $value, $args, $param ); if ( ! $best_type ) { - /* translators: 1: Parameter, 2: List of types. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, implode( ',', $args['type'] ) ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: List of types. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, implode( ',', $args['type'] ) ), + array( 'param' => $param ) + ); } $args['type'] = $best_type; @@ -1715,8 +1952,12 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { if ( 'array' === $args['type'] ) { if ( ! rest_is_array( $value ) ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, 'array' ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, 'array' ), + array( 'param' => $param ) + ); } $value = rest_sanitize_array( $value ); @@ -1748,8 +1989,12 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { if ( 'object' === $args['type'] ) { if ( ! rest_is_object( $value ) ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, 'object' ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, 'object' ), + array( 'param' => $param ) + ); } $value = rest_sanitize_object( $value ); @@ -1816,8 +2061,12 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { if ( 'null' === $args['type'] ) { if ( null !== $value ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, 'null' ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, 'null' ), + array( 'param' => $param ) + ); } return true; @@ -1832,8 +2081,12 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { if ( in_array( $args['type'], array( 'integer', 'number' ), true ) ) { if ( ! is_numeric( $value ) ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, $args['type'] ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, $args['type'] ), + array( 'param' => $param ) + ); } if ( isset( $args['multipleOf'] ) && fmod( $value, $args['multipleOf'] ) !== 0.0 ) { @@ -1843,19 +2096,31 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { } if ( 'integer' === $args['type'] && ! rest_is_integer( $value ) ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, 'integer' ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, 'integer' ), + array( 'param' => $param ) + ); } if ( 'boolean' === $args['type'] && ! rest_is_boolean( $value ) ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, 'boolean' ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, 'boolean' ), + array( 'param' => $param ) + ); } if ( 'string' === $args['type'] ) { if ( ! is_string( $value ) ) { - /* translators: 1: Parameter, 2: Type name. */ - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not of type %2$s.' ), $param, 'string' ) ); + return new WP_Error( + 'rest_invalid_type', + /* translators: 1: Parameter, 2: Type name. */ + sprintf( __( '%1$s is not of type %2$s.' ), $param, 'string' ), + array( 'param' => $param ) + ); } if ( isset( $args['minLength'] ) && mb_strlen( $value ) < $args['minLength'] ) { @@ -1976,6 +2241,7 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { * * @since 4.7.0 * @since 5.5.0 Added the `$param` parameter. + * @since 5.6.0 Support the "anyOf" and "oneOf" keywords. * * @param mixed $value The value to sanitize. * @param array $args Schema array to use for sanitization. @@ -1983,6 +2249,32 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { * @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 = '' ) { + if ( isset( $args['anyOf'] ) ) { + $matching_schema = rest_find_any_matching_schema( $value, $args, $param ); + if ( is_wp_error( $matching_schema ) ) { + return $matching_schema; + } + + if ( ! isset( $args['type'] ) ) { + $args['type'] = $matching_schema['type']; + } + + $value = rest_sanitize_value_from_schema( $value, $matching_schema, $param ); + } + + if ( isset( $args['oneOf'] ) ) { + $matching_schema = rest_find_one_matching_schema( $value, $args, $param ); + if ( is_wp_error( $matching_schema ) ) { + return $matching_schema; + } + + if ( ! isset( $args['type'] ) ) { + $args['type'] = $matching_schema['type']; + } + + $value = rest_sanitize_value_from_schema( $value, $matching_schema, $param ); + } + $allowed_types = array( 'array', 'object', 'string', 'number', 'integer', 'boolean', 'null' ); if ( ! isset( $args['type'] ) ) { @@ -2198,6 +2490,7 @@ function rest_parse_embed_param( $embed ) { * * @since 5.5.0 * @since 5.6.0 Support the "patternProperties" keyword for objects. + * Support the "anyOf" and "oneOf" keywords. * * @param array|object $data The response data to modify. * @param array $schema The schema for the endpoint used to filter the response. @@ -2205,6 +2498,28 @@ function rest_parse_embed_param( $embed ) { * @return array|object The filtered response data. */ function rest_filter_response_by_context( $data, $schema, $context ) { + if ( isset( $schema['anyOf'] ) ) { + $matching_schema = rest_find_any_matching_schema( $data, $schema, '' ); + if ( ! is_wp_error( $matching_schema ) ) { + if ( ! isset( $schema['type'] ) ) { + $schema['type'] = $matching_schema['type']; + } + + $data = rest_filter_response_by_context( $data, $matching_schema, $context ); + } + } + + if ( isset( $schema['oneOf'] ) ) { + $matching_schema = rest_find_one_matching_schema( $data, $schema, '', true ); + if ( ! is_wp_error( $matching_schema ) ) { + if ( ! isset( $schema['type'] ) ) { + $schema['type'] = $matching_schema['type']; + } + + $data = rest_filter_response_by_context( $data, $matching_schema, $context ); + } + } + if ( ! is_array( $data ) && ! is_object( $data ) ) { return $data; } @@ -2471,6 +2786,8 @@ function rest_get_endpoint_args_for_schema( $schema, $method = WP_REST_Server::C 'minItems', 'maxItems', 'uniqueItems', + 'anyOf', + 'oneOf', ); foreach ( $schema_properties as $field_id => $params ) { diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index 580a4e3439..e4532289ff 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -1085,7 +1085,7 @@ class Tests_REST_API extends WP_UnitTestCase { public function _dp_rest_filter_response_by_context() { return array( - 'default' => array( + 'default' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1106,7 +1106,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'first' => 'a' ), ), - 'keeps missing context' => array( + 'keeps missing context' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1129,7 +1129,7 @@ class Tests_REST_API extends WP_UnitTestCase { 'second' => 'b', ), ), - 'removes empty context' => array( + 'removes empty context' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1150,7 +1150,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'first' => 'a' ), ), - 'nested properties' => array( + 'nested properties' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1179,7 +1179,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'parent' => array( 'child' => 'hi' ) ), ), - 'grand child properties' => array( + 'grand child properties' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1215,7 +1215,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'parent' => array( 'child' => array( 'grand' => 'hi' ) ) ), ), - 'array' => array( + 'array' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1250,7 +1250,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'arr' => array( array( 'visible' => 'hi' ) ) ), ), - 'additional properties' => array( + 'additional properties' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1284,7 +1284,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'additional' => array( 'a' => '1' ) ), ), - 'pattern properties' => array( + 'pattern properties' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1320,7 +1320,7 @@ class Tests_REST_API extends WP_UnitTestCase { '0' => '3', ), ), - 'multiple types object' => array( + 'multiple types object' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1349,7 +1349,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'multi' => array( 'a' => '1' ) ), ), - 'multiple types array' => array( + 'multiple types array' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1384,7 +1384,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'multi' => array( array( 'visible' => '1' ) ) ), ), - 'does not traverse missing context' => array( + 'does not traverse missing context' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1427,7 +1427,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), ), ), - 'object with no matching properties' => array( + 'object with no matching properties' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1448,7 +1448,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array(), ), - 'array whose type does not match' => array( + 'array whose type does not match' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', @@ -1468,7 +1468,7 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'arr' => array() ), ), - 'array and object type passed object' => array( + 'array and object type passed object' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => array( 'array', 'object' ), @@ -1506,7 +1506,7 @@ class Tests_REST_API extends WP_UnitTestCase { 'b' => 'bar', ), ), - 'array and object type passed array' => array( + 'array and object type passed array' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => array( 'array', 'object' ), @@ -1547,6 +1547,210 @@ class Tests_REST_API extends WP_UnitTestCase { ), array(), ), + 'anyOf applies the correct schema' => array( + array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'anyOf' => array( + array( + 'properties' => array( + 'a' => array( + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'b' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + ), + array( + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'context' => array( 'edit' ), + ), + 'b' => array( + 'type' => 'integer', + 'context' => array( 'view' ), + ), + ), + ), + ), + ), + array( + 'a' => 1, + 'b' => 2, + ), + array( + 'b' => 2, + ), + ), + 'anyOf is ignored if no valid schema is found' => array( + array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'anyOf' => array( + array( + 'properties' => array( + 'a' => array( + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'b' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + ), + array( + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'context' => array( 'edit' ), + ), + 'b' => array( + 'type' => 'integer', + 'context' => array( 'view' ), + ), + ), + ), + ), + ), + array( + 'a' => true, + 'b' => false, + ), + array( + 'a' => true, + 'b' => false, + ), + ), + 'oneOf applies the correct schema' => array( + array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'oneOf' => array( + array( + 'properties' => array( + 'a' => array( + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'b' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + ), + array( + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'context' => array( 'edit' ), + ), + 'b' => array( + 'type' => 'integer', + 'context' => array( 'view' ), + ), + ), + ), + ), + ), + array( + 'a' => 1, + 'b' => 2, + ), + array( + 'b' => 2, + ), + ), + 'oneOf ignored if no valid schema was found' => array( + array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'anyOf' => array( + array( + 'properties' => array( + 'a' => array( + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'b' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + ), + array( + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'context' => array( 'edit' ), + ), + 'b' => array( + 'type' => 'integer', + 'context' => array( 'view' ), + ), + ), + ), + ), + ), + array( + 'a' => true, + 'b' => false, + ), + array( + 'a' => true, + 'b' => false, + ), + ), + 'oneOf combined with base' => array( + array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => array( + 'c' => array( + 'type' => 'integer', + 'context' => array( 'edit' ), + ), + ), + 'oneOf' => array( + array( + 'properties' => array( + 'a' => array( + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'b' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + ), + array( + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'context' => array( 'edit' ), + ), + 'b' => array( + 'type' => 'integer', + 'context' => array( 'view' ), + ), + ), + ), + ), + ), + array( + 'a' => 1, + 'b' => 2, + 'c' => 3, + ), + array( + 'b' => 2, + ), + ), ); } diff --git a/tests/phpunit/tests/rest-api/json_schema_test_suite/anyof.json b/tests/phpunit/tests/rest-api/json_schema_test_suite/anyof.json new file mode 100644 index 0000000000..583f67e7d6 --- /dev/null +++ b/tests/phpunit/tests/rest-api/json_schema_test_suite/anyof.json @@ -0,0 +1,229 @@ +[ + { + "description": "anyOf", + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": [ + "integer", + "number" + ], + "minimum": 2 + } + ] + }, + "tests": [ + { + "description": "first anyOf valid", + "data": 1, + "valid": true + }, + { + "description": "second anyOf valid", + "data": 2.5, + "valid": true + }, + { + "description": "both anyOf valid", + "data": 3, + "valid": true + }, + { + "description": "neither anyOf valid", + "data": 1.5, + "valid": false + } + ] + }, + { + "description": "anyOf with base schema", + "schema": { + "type": "string", + "anyOf": [ + { + "maxLength": 2 + }, + { + "minLength": 4 + } + ] + }, + "tests": [ + { + "description": "mismatch base schema", + "data": 3, + "valid": false + }, + { + "description": "one anyOf valid", + "data": "foobar", + "valid": true + }, + { + "description": "both anyOf invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "anyOf with boolean schemas, all true", + "schema": { + "anyOf": [ + true, + true + ] + }, + "tests": [ + { + "description": "any value is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "anyOf with boolean schemas, some true", + "schema": { + "anyOf": [ + true, + false + ] + }, + "tests": [ + { + "description": "any value is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "anyOf with boolean schemas, all false", + "schema": { + "anyOf": [ + false, + false + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "anyOf complex types", + "schema": { + "type": "object", + "anyOf": [ + { + "properties": { + "bar": { + "type": "integer" + } + }, + "required": [ + "bar" + ] + }, + { + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ] + }, + "tests": [ + { + "description": "first anyOf valid (complex)", + "data": { + "bar": 2 + }, + "valid": true + }, + { + "description": "second anyOf valid (complex)", + "data": { + "foo": "baz" + }, + "valid": true + }, + { + "description": "both anyOf valid (complex)", + "data": { + "foo": "baz", + "bar": 2 + }, + "valid": true + }, + { + "description": "neither anyOf valid (complex)", + "data": { + "foo": 2, + "bar": "quux" + }, + "valid": false + } + ] + }, + { + "description": "anyOf with one empty schema", + "schema": { + "anyOf": [ + { + "type": "number" + }, + {} + ] + }, + "tests": [ + { + "description": "string is valid", + "data": "foo", + "valid": true + }, + { + "description": "number is valid", + "data": 123, + "valid": true + } + ] + }, + { + "description": "nested anyOf, to check validation semantics", + "schema": { + "anyOf": [ + { + "anyOf": [ + { + "type": "null" + } + ] + } + ] + }, + "tests": [ + { + "description": "null is valid", + "data": null, + "valid": true + }, + { + "description": "anything non-null is invalid", + "data": 123, + "valid": false + } + ] + } +] diff --git a/tests/phpunit/tests/rest-api/json_schema_test_suite/oneof.json b/tests/phpunit/tests/rest-api/json_schema_test_suite/oneof.json new file mode 100644 index 0000000000..2e9861251d --- /dev/null +++ b/tests/phpunit/tests/rest-api/json_schema_test_suite/oneof.json @@ -0,0 +1,365 @@ +[ + { + "description": "oneOf", + "schema": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": [ + "number", + "integer" + ], + "minimum": 2 + } + ] + }, + "tests": [ + { + "description": "first oneOf valid", + "data": 1, + "valid": true + }, + { + "description": "second oneOf valid", + "data": 2.5, + "valid": true + }, + { + "description": "both oneOf valid", + "data": 3, + "valid": false + }, + { + "description": "neither oneOf valid", + "data": 1.5, + "valid": false + } + ] + }, + { + "description": "oneOf with base schema", + "schema": { + "type": "string", + "oneOf": [ + { + "minLength": 2 + }, + { + "maxLength": 4 + } + ] + }, + "tests": [ + { + "description": "mismatch base schema", + "data": 3, + "valid": false + }, + { + "description": "one oneOf valid", + "data": "foobar", + "valid": true + }, + { + "description": "both oneOf valid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf with boolean schemas, all true", + "schema": { + "oneOf": [ + true, + true, + true + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf with boolean schemas, one true", + "schema": { + "oneOf": [ + true, + false, + false + ] + }, + "tests": [ + { + "description": "any value is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "oneOf with boolean schemas, more than one true", + "schema": { + "oneOf": [ + true, + true, + false + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf with boolean schemas, all false", + "schema": { + "oneOf": [ + false, + false, + false + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf complex types", + "schema": { + "type": "object", + "oneOf": [ + { + "properties": { + "bar": { + "type": "integer" + } + }, + "required": [ + "bar" + ] + }, + { + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ] + }, + "tests": [ + { + "description": "first oneOf valid (complex)", + "data": { + "bar": 2 + }, + "valid": true + }, + { + "description": "second oneOf valid (complex)", + "data": { + "foo": "baz" + }, + "valid": true + }, + { + "description": "both oneOf valid (complex)", + "data": { + "foo": "baz", + "bar": 2 + }, + "valid": false + }, + { + "description": "neither oneOf valid (complex)", + "data": { + "foo": 2, + "bar": "quux" + }, + "valid": false + } + ] + }, + { + "description": "oneOf with empty schema", + "schema": { + "oneOf": [ + { + "type": "number" + }, + {} + ] + }, + "tests": [ + { + "description": "one valid - valid", + "data": "foo", + "valid": true + }, + { + "description": "both valid - invalid", + "data": 123, + "valid": false + } + ] + }, + { + "description": "oneOf with required", + "schema": { + "type": "object", + "oneOf": [ + { + "required": [ + "foo", + "bar" + ] + }, + { + "required": [ + "foo", + "baz" + ] + } + ] + }, + "tests": [ + { + "description": "both invalid - invalid", + "data": { + "bar": 2 + }, + "valid": false + }, + { + "description": "first valid - valid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": true + }, + { + "description": "second valid - valid", + "data": { + "foo": 1, + "baz": 3 + }, + "valid": true + }, + { + "description": "both valid - invalid", + "data": { + "foo": 1, + "bar": 2, + "baz": 3 + }, + "valid": false + } + ] + }, + { + "description": "oneOf with missing optional property", + "schema": { + "type": "object", + "oneOf": [ + { + "properties": { + "bar": { + "type": "integer" + }, + "baz": { + "type": "string" + } + }, + "required": [ + "bar" + ] + }, + { + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ] + }, + "tests": [ + { + "description": "first oneOf valid", + "data": { + "bar": 8 + }, + "valid": true + }, + { + "description": "second oneOf valid", + "data": { + "foo": "foo" + }, + "valid": true + }, + { + "description": "both oneOf valid", + "data": { + "foo": "foo", + "bar": 8 + }, + "valid": false + }, + { + "description": "neither oneOf valid", + "data": { + "baz": "quux" + }, + "valid": false + } + ] + }, + { + "description": "nested oneOf, to check validation semantics", + "schema": { + "oneOf": [ + { + "oneOf": [ + { + "type": "null" + } + ] + } + ] + }, + "tests": [ + { + "description": "null is valid", + "data": null, + "valid": true + }, + { + "description": "anything non-null is invalid", + "data": 123, + "valid": false + } + ] + } +] diff --git a/tests/phpunit/tests/rest-api/rest-controller.php b/tests/phpunit/tests/rest-api/rest-controller.php index 70c80d230d..07ce3d5d0e 100644 --- a/tests/phpunit/tests/rest-api/rest-controller.php +++ b/tests/phpunit/tests/rest-api/rest-controller.php @@ -66,7 +66,7 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase { ); $this->assertErrorResponse( - 'rest_invalid_param', + 'rest_invalid_type', rest_validate_request_arg( 'abc', $this->request, 'someinteger' ) ); } @@ -140,7 +140,7 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase { ); $this->assertErrorResponse( - 'rest_invalid_param', + 'rest_invalid_type', rest_validate_request_arg( '123', $this->request, 'someboolean' ) ); } @@ -152,7 +152,7 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase { ); $this->assertErrorResponse( - 'rest_invalid_param', + 'rest_invalid_type', rest_validate_request_arg( array( 'foo' => 'bar' ), $this->request, 'somestring' ) ); } @@ -297,6 +297,8 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase { 'additionalProperties', 'minProperties', 'maxProperties', + 'anyOf', + 'oneOf', ); foreach ( $object_properties as $property ) { $this->assertArrayHasKey( $property, $args['someobject'] ); diff --git a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php index dd17c54a8a..d326b5fb9d 100644 --- a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php +++ b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php @@ -692,7 +692,7 @@ class WP_Test_REST_Post_Meta_Fields extends WP_Test_REST_TestCase { $request->set_body_params( $data ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertErrorResponse( 'rest_invalid_type', $response, 400 ); } public function test_set_value_invalid_value_multiple() { @@ -717,7 +717,7 @@ class WP_Test_REST_Post_Meta_Fields extends WP_Test_REST_TestCase { $request->set_body_params( $data ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertErrorResponse( 'rest_invalid_type', $response, 400 ); } public function test_set_value_sanitized() { diff --git a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php index b2a247e015..a5c277ac04 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php +++ b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php @@ -587,4 +587,51 @@ class WP_Test_REST_Schema_Sanitization extends WP_UnitTestCase { $this->assertTrue( rest_validate_value_from_schema( $data, $schema ) ); $this->assertWPError( rest_sanitize_value_from_schema( $data, $schema ) ); } + + /** + * @ticket 51025 + */ + public function test_any_of() { + $schema = array( + 'anyOf' => array( + array( + 'type' => 'integer', + 'multipleOf' => 2, + ), + array( + 'type' => 'string', + 'maxLength' => 1, + ), + ), + ); + + $this->assertSame( 4, rest_sanitize_value_from_schema( '4', $schema ) ); + $this->assertSame( '5', rest_sanitize_value_from_schema( '5', $schema ) ); + $this->assertWPError( rest_sanitize_value_from_schema( true, $schema ) ); + $this->assertWPError( rest_sanitize_value_from_schema( '11', $schema ) ); + } + + /** + * @ticket 51025 + */ + public function test_one_of() { + $schema = array( + 'oneOf' => array( + array( + 'type' => 'integer', + 'multipleOf' => 2, + ), + array( + 'type' => 'string', + 'maxLength' => 1, + ), + ), + ); + + $this->assertSame( 10, rest_sanitize_value_from_schema( '10', $schema ) ); + $this->assertSame( '5', rest_sanitize_value_from_schema( '5', $schema ) ); + $this->assertWPError( rest_sanitize_value_from_schema( true, $schema ) ); + $this->assertWPError( rest_sanitize_value_from_schema( '11', $schema ) ); + $this->assertWPError( rest_sanitize_value_from_schema( '4', $schema ) ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php index d1064005cf..f33990ae59 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-validation.php +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -1253,4 +1253,312 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase { $this->assertWPError( rest_validate_value_from_schema( 15.5, $schema ) ); } + /** + * @ticket 51025 + * + * @dataProvider data_any_of + * + * @param array $data + * @param array $schema + * @param bool $valid + */ + public function test_any_of( $data, $schema, $valid ) { + $is_valid = rest_validate_value_from_schema( $data, $schema ); + + if ( $valid ) { + $this->assertTrue( $is_valid ); + } else { + $this->assertWPError( $is_valid ); + } + } + + /** + * @return array + */ + public function data_any_of() { + $suites = json_decode( file_get_contents( __DIR__ . '/json_schema_test_suite/anyof.json' ), true ); + $skip = array( + 'anyOf with boolean schemas, all true', + 'anyOf with boolean schemas, some true', + 'anyOf with boolean schemas, all false', + 'anyOf with one empty schema', + 'nested anyOf, to check validation semantics', + ); + + $tests = array(); + + foreach ( $suites as $suite ) { + if ( in_array( $suite['description'], $skip, true ) ) { + continue; + } + + foreach ( $suite['tests'] as $test ) { + $tests[ $suite['description'] . ': ' . $test['description'] ] = array( + $test['data'], + $suite['schema'], + $test['valid'], + ); + } + } + + return $tests; + } + + /** + * @ticket 51025 + * + * @dataProvider data_one_of + * + * @param array $data + * @param array $schema + * @param bool $valid + */ + public function test_one_of( $data, $schema, $valid ) { + $is_valid = rest_validate_value_from_schema( $data, $schema ); + + if ( $valid ) { + $this->assertTrue( $is_valid ); + } else { + $this->assertWPError( $is_valid ); + } + } + + /** + * @return array + */ + public function data_one_of() { + $suites = json_decode( file_get_contents( __DIR__ . '/json_schema_test_suite/oneof.json' ), true ); + $skip = array( + 'oneOf with boolean schemas, all true', + 'oneOf with boolean schemas, one true', + 'oneOf with boolean schemas, more than one true', + 'oneOf with boolean schemas, all false', + 'oneOf with empty schema', + 'nested oneOf, to check validation semantics', + ); + + $tests = array(); + + foreach ( $suites as $suite ) { + if ( in_array( $suite['description'], $skip, true ) ) { + continue; + } + + foreach ( $suite['tests'] as $test ) { + $tests[ $suite['description'] . ': ' . $test['description'] ] = array( + $test['data'], + $suite['schema'], + $test['valid'], + ); + } + } + + return $tests; + } + + /** + * @ticket 51025 + * + * @dataProvider data_combining_operation_error_message + * + * @param $data + * @param $schema + * @param $expected + */ + public function test_combining_operation_error_message( $data, $schema, $expected ) { + $is_valid = rest_validate_value_from_schema( $data, $schema, 'foo' ); + + $this->assertWPError( $is_valid ); + $this->assertSame( $expected, $is_valid->get_error_message() ); + } + + /** + * @return array + */ + public function data_combining_operation_error_message() { + return array( + array( + 10, + array( + 'anyOf' => array( + array( + 'title' => 'circle', + 'type' => 'integer', + 'maximum' => 5, + ), + ), + ), + 'foo is not a valid circle. Reason: foo must be less than or equal to 5', + ), + array( + 10, + array( + 'anyOf' => array( + array( + 'type' => 'integer', + 'maximum' => 5, + ), + ), + ), + 'foo does not match the expected format. Reason: foo must be less than or equal to 5', + ), + array( + array( 'a' => 1 ), + array( + 'anyOf' => array( + array( 'type' => 'boolean' ), + array( + 'title' => 'circle', + 'type' => 'object', + 'properties' => array( + 'a' => array( 'type' => 'string' ), + ), + ), + ), + ), + 'foo is not a valid circle. Reason: foo[a] is not of type string.', + ), + array( + array( 'a' => 1 ), + array( + 'anyOf' => array( + array( 'type' => 'boolean' ), + array( + 'type' => 'object', + 'properties' => array( + 'a' => array( 'type' => 'string' ), + ), + ), + ), + ), + 'foo does not match the expected format. Reason: foo[a] is not of type string.', + ), + array( + array( + 'a' => 1, + 'b' => 2, + 'c' => 3, + ), + array( + 'anyOf' => array( + array( 'type' => 'boolean' ), + array( + 'type' => 'object', + 'properties' => array( + 'a' => array( 'type' => 'string' ), + ), + ), + array( + 'title' => 'square', + 'type' => 'object', + 'properties' => array( + 'b' => array( 'type' => 'string' ), + 'c' => array( 'type' => 'string' ), + ), + ), + array( + 'type' => 'object', + 'properties' => array( + 'b' => array( 'type' => 'boolean' ), + 'x' => array( 'type' => 'boolean' ), + ), + ), + ), + ), + 'foo is not a valid square. Reason: foo[b] is not of type string.', + ), + array( + array( + 'a' => 1, + 'b' => 2, + 'c' => 3, + ), + array( + 'anyOf' => array( + array( 'type' => 'boolean' ), + array( + 'type' => 'object', + 'properties' => array( + 'a' => array( 'type' => 'string' ), + ), + ), + array( + 'type' => 'object', + 'properties' => array( + 'b' => array( 'type' => 'string' ), + 'c' => array( 'type' => 'string' ), + ), + ), + array( + 'type' => 'object', + 'properties' => array( + 'b' => array( 'type' => 'boolean' ), + 'x' => array( 'type' => 'boolean' ), + ), + ), + ), + ), + 'foo does not match the expected format. Reason: foo[b] is not of type string.', + ), + array( + 'test', + array( + 'anyOf' => array( + array( + 'title' => 'circle', + 'type' => 'boolean', + ), + array( + 'title' => 'square', + 'type' => 'integer', + ), + array( + 'title' => 'triangle', + 'type' => 'null', + ), + ), + ), + 'foo is not a valid circle, square, and triangle.', + ), + array( + 'test', + array( + 'anyOf' => array( + array( 'type' => 'boolean' ), + array( 'type' => 'integer' ), + array( 'type' => 'null' ), + ), + ), + 'foo does not match any of the expected formats.', + ), + array( + 'test', + array( + 'oneOf' => array( + array( + 'title' => 'circle', + 'type' => 'string', + ), + array( 'type' => 'integer' ), + array( + 'title' => 'triangle', + 'type' => 'string', + ), + ), + ), + 'foo matches circle and triangle, but should match only one.', + ), + array( + 'test', + array( + 'oneOf' => array( + array( 'type' => 'string' ), + array( 'type' => 'integer' ), + array( 'type' => 'string' ), + ), + ), + 'foo matches more than one of the expected formats.', + ), + ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-term-meta-fields.php b/tests/phpunit/tests/rest-api/rest-term-meta-fields.php index 9038e01998..97dd6048fe 100644 --- a/tests/phpunit/tests/rest-api/rest-term-meta-fields.php +++ b/tests/phpunit/tests/rest-api/rest-term-meta-fields.php @@ -639,7 +639,7 @@ class WP_Test_REST_Term_Meta_Fields extends WP_Test_REST_TestCase { $request->set_body_params( $data ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertErrorResponse( 'rest_invalid_type', $response, 400 ); } public function test_set_value_invalid_value_multiple() { @@ -664,7 +664,7 @@ class WP_Test_REST_Term_Meta_Fields extends WP_Test_REST_TestCase { $request->set_body_params( $data ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertErrorResponse( 'rest_invalid_type', $response, 400 ); } public function test_set_value_sanitized() { diff --git a/tests/phpunit/tests/rest-api/rest-test-controller.php b/tests/phpunit/tests/rest-api/rest-test-controller.php index ab7dbe0cfb..d09cb5e7a3 100644 --- a/tests/phpunit/tests/rest-api/rest-test-controller.php +++ b/tests/phpunit/tests/rest-api/rest-test-controller.php @@ -128,6 +128,42 @@ class WP_REST_Test_Controller extends WP_REST_Controller { ), 'minProperties' => 1, 'maxProperties' => 10, + 'anyOf' => array( + array( + 'properties' => array( + 'object_id' => array( + 'type' => 'integer', + 'minimum' => 100, + ), + ), + ), + array( + 'properties' => array( + 'object_id' => array( + 'type' => 'integer', + 'maximum' => 100, + ), + ), + ), + ), + 'oneOf' => array( + array( + 'properties' => array( + 'object_id' => array( + 'type' => 'integer', + 'minimum' => 100, + ), + ), + ), + array( + 'properties' => array( + 'object_id' => array( + 'type' => 'integer', + 'maximum' => 100, + ), + ), + ), + ), 'ignored_prop' => 'ignored_prop', 'context' => array( 'view' ), ),