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' ), ),