From f60aa519882babdf934fda8f974c05fef84607ef Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Thu, 1 Oct 2020 02:47:08 +0000 Subject: [PATCH] REST API: Support the patternProperties JSON Schema keyword. Props yakimun. Fixes #51024. git-svn-id: https://develop.svn.wordpress.org/trunk@49082 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/rest-api.php | 89 +++++++++++++-- tests/phpunit/tests/rest-api.php | 36 +++++++ .../tests/rest-api/rest-controller.php | 9 +- .../rest-api/rest-schema-sanitization.php | 96 +++++++++++++++++ .../tests/rest-api/rest-schema-validation.php | 101 ++++++++++++++++++ .../tests/rest-api/rest-test-controller.php | 5 + 6 files changed, 325 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 99cdd3e749..6bfba39ca8 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -1539,6 +1539,42 @@ function rest_stabilize_value( $value ) { return $value; } +/** + * Validates if the JSON Schema pattern matches a value. + * + * @since 5.6.0 + * + * @param string $pattern The pattern to match against. + * @param string $value The value to check. + * @return bool True if the pattern matches the given value, false otherwise. + */ +function rest_validate_json_schema_pattern( $pattern, $value ) { + $escaped_pattern = str_replace( '#', '\\#', $pattern ); + + return 1 === preg_match( '#' . $escaped_pattern . '#u', $value ); +} + +/** + * Finds the schema for a property using the patternProperties keyword. + * + * @since 5.6.0 + * + * @param string $property The property name to check. + * @param array $args The schema array to use. + * @return array|null The schema of matching pattern property, or null if no patterns match. + */ +function rest_find_matching_pattern_property_schema( $property, $args ) { + if ( isset( $args['patternProperties'] ) ) { + foreach ( $args['patternProperties'] as $pattern => $child_schema ) { + if ( rest_validate_json_schema_pattern( $pattern, $property ) ) { + return $child_schema; + } + } + } + + return null; +} + /** * Validate a value based on a schema. * @@ -1553,6 +1589,7 @@ function rest_stabilize_value( $value ) { * Validate required properties. * @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. * * @param mixed $value The value to validate. * @param array $args Schema array to use for validation. @@ -1650,7 +1687,19 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { if ( is_wp_error( $is_valid ) ) { return $is_valid; } - } elseif ( isset( $args['additionalProperties'] ) ) { + continue; + } + + $pattern_property_schema = rest_find_matching_pattern_property_schema( $property, $args ); + if ( null !== $pattern_property_schema ) { + $is_valid = rest_validate_value_from_schema( $v, $pattern_property_schema, $param . '[' . $property . ']' ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + continue; + } + + if ( isset( $args['additionalProperties'] ) ) { if ( false === $args['additionalProperties'] ) { /* translators: %s: Property of an object. */ return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s is not a valid property of Object.' ), $property ) ); @@ -1744,12 +1793,9 @@ function rest_validate_value_from_schema( $value, $args, $param = '' ) { ); } - if ( isset( $args['pattern'] ) ) { - $pattern = str_replace( '#', '\\#', $args['pattern'] ); - if ( ! preg_match( '#' . $pattern . '#u', $value ) ) { - /* translators: 1: Parameter, 2: Pattern. */ - return new WP_Error( 'rest_invalid_pattern', sprintf( __( '%1$s does not match pattern %2$s.' ), $param, $args['pattern'] ) ); - } + if ( isset( $args['pattern'] ) && ! rest_validate_json_schema_pattern( $args['pattern'], $value ) ) { + /* translators: 1: Parameter, 2: Pattern. */ + return new WP_Error( 'rest_invalid_pattern', sprintf( __( '%1$s does not match pattern %2$s.' ), $param, $args['pattern'] ) ); } } @@ -1897,7 +1943,16 @@ function rest_sanitize_value_from_schema( $value, $args, $param = '' ) { foreach ( $value as $property => $v ) { if ( isset( $args['properties'][ $property ] ) ) { $value[ $property ] = rest_sanitize_value_from_schema( $v, $args['properties'][ $property ], $param . '[' . $property . ']' ); - } elseif ( isset( $args['additionalProperties'] ) ) { + continue; + } + + $pattern_property_schema = rest_find_matching_pattern_property_schema( $property, $args ); + if ( null !== $pattern_property_schema ) { + $value[ $property ] = rest_sanitize_value_from_schema( $v, $pattern_property_schema, $param . '[' . $property . ']' ); + continue; + } + + if ( isset( $args['additionalProperties'] ) ) { if ( false === $args['additionalProperties'] ) { unset( $value[ $property ] ); } elseif ( is_array( $args['additionalProperties'] ) ) { @@ -2053,6 +2108,7 @@ function rest_parse_embed_param( $embed ) { * Filters the response to remove any fields not available in the given context. * * @since 5.5.0 + * @since 5.6.0 Support the "patternProperties" keyword for objects. * * @param array|object $data The response data to modify. * @param array $schema The schema for the endpoint used to filter the response. @@ -2093,8 +2149,13 @@ function rest_filter_response_by_context( $data, $schema, $context ) { } elseif ( $is_object_type ) { if ( isset( $schema['properties'][ $key ] ) ) { $check = $schema['properties'][ $key ]; - } elseif ( $has_additional_properties ) { - $check = $schema['additionalProperties']; + } else { + $pattern_property_schema = rest_find_matching_pattern_property_schema( $key, $schema ); + if ( null !== $pattern_property_schema ) { + $check = $pattern_property_schema; + } elseif ( $has_additional_properties ) { + $check = $schema['additionalProperties']; + } } } @@ -2132,6 +2193,7 @@ function rest_filter_response_by_context( $data, $schema, $context ) { * Sets the "additionalProperties" to false by default for all object definitions in the schema. * * @since 5.5.0 + * @since 5.6.0 Support the "patternProperties" keyword. * * @param array $schema The schema to modify. * @return array The modified schema. @@ -2146,6 +2208,12 @@ function rest_default_additional_properties_to_false( $schema ) { } } + if ( isset( $schema['patternProperties'] ) ) { + foreach ( $schema['patternProperties'] as $key => $child_schema ) { + $schema['patternProperties'][ $key ] = rest_default_additional_properties_to_false( $child_schema ); + } + } + if ( ! isset( $schema['additionalProperties'] ) ) { $schema['additionalProperties'] = false; } @@ -2300,6 +2368,7 @@ function rest_get_endpoint_args_for_schema( $schema, $method = WP_REST_Server::C 'items', 'properties', 'additionalProperties', + 'patternProperties', 'minProperties', 'maxProperties', 'minimum', diff --git a/tests/phpunit/tests/rest-api.php b/tests/phpunit/tests/rest-api.php index 08f498e5bb..580a4e3439 100644 --- a/tests/phpunit/tests/rest-api.php +++ b/tests/phpunit/tests/rest-api.php @@ -1284,6 +1284,42 @@ class Tests_REST_API extends WP_UnitTestCase { ), array( 'additional' => array( 'a' => '1' ) ), ), + 'pattern properties' => array( + array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + ), + 'patternProperties' => array( + '[0-9]' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'c.*' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + 'additionalProperties' => array( + 'type' => 'string', + 'context' => array( 'edit' ), + ), + ), + array( + 'a' => '1', + 'b' => '2', + '0' => '3', + 'ca' => '4', + ), + array( + 'a' => '1', + '0' => '3', + ), + ), 'multiple types object' => array( array( '$schema' => 'http://json-schema.org/draft-04/schema#', diff --git a/tests/phpunit/tests/rest-api/rest-controller.php b/tests/phpunit/tests/rest-api/rest-controller.php index d6dc7f8fae..70c80d230d 100644 --- a/tests/phpunit/tests/rest-api/rest-controller.php +++ b/tests/phpunit/tests/rest-api/rest-controller.php @@ -291,7 +291,14 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase { $this->assertArrayHasKey( $property, $args['somearray'] ); } - foreach ( array( 'properties', 'additionalProperties', 'minProperties', 'maxProperties' ) as $property ) { + $object_properties = array( + 'properties', + 'patternProperties', + 'additionalProperties', + 'minProperties', + 'maxProperties', + ); + foreach ( $object_properties as $property ) { $this->assertArrayHasKey( $property, $args['someobject'] ); } diff --git a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php index 242c2f65e7..b2a247e015 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-sanitization.php +++ b/tests/phpunit/tests/rest-api/rest-schema-sanitization.php @@ -237,6 +237,102 @@ class WP_Test_REST_Schema_Sanitization extends WP_UnitTestCase { ); } + /** + * @ticket 51024 + * + * @dataProvider data_type_object_pattern_properties + * + * @param array $pattern_properties + * @param array $value + * @param array $expected + */ + public function test_type_object_pattern_properties( $pattern_properties, $value, $expected ) { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'propA' => array( 'type' => 'string' ), + ), + 'patternProperties' => $pattern_properties, + 'additionalProperties' => false, + ); + + $this->assertSame( $expected, rest_sanitize_value_from_schema( $value, $schema ) ); + } + + /** + * @return array + */ + public function data_type_object_pattern_properties() { + return array( + array( array(), array(), array() ), + array( array(), array( 'propA' => 'a' ), array( 'propA' => 'a' ) ), + array( + array(), + array( + 'propA' => 'a', + 'propB' => 'b', + ), + array( 'propA' => 'a' ), + ), + array( + array( + 'propB' => array( 'type' => 'string' ), + ), + array( 'propA' => 'a' ), + array( 'propA' => 'a' ), + ), + array( + array( + 'propB' => array( 'type' => 'string' ), + ), + array( + 'propA' => 'a', + 'propB' => 'b', + ), + array( + 'propA' => 'a', + 'propB' => 'b', + ), + ), + array( + array( + '.*C' => array( 'type' => 'string' ), + ), + array( + 'propA' => 'a', + 'propC' => 'c', + ), + array( + 'propA' => 'a', + 'propC' => 'c', + ), + ), + array( + array( + '[0-9]' => array( 'type' => 'integer' ), + ), + array( + 'propA' => 'a', + 'prop0' => '0', + ), + array( + 'propA' => 'a', + 'prop0' => 0, + ), + ), + array( + array( + '.+' => array( 'type' => 'string' ), + ), + array( + '' => '', + 'propA' => 'a', + ), + array( 'propA' => 'a' ), + ), + ); + } + public function test_type_object_nested() { $schema = array( 'type' => 'object', diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php index 77a2c2ec14..d1064005cf 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-validation.php +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -288,6 +288,107 @@ class WP_Test_REST_Schema_Validation extends WP_UnitTestCase { $this->assertWPError( rest_validate_value_from_schema( array( 'a' => 'invalid' ), $schema ) ); } + /** + * @ticket 51024 + * + * @dataProvider data_type_object_pattern_properties + * + * @param array $pattern_properties + * @param array $value + * @param bool $expected + */ + public function test_type_object_pattern_properties( $pattern_properties, $value, $expected ) { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'propA' => array( 'type' => 'string' ), + ), + 'patternProperties' => $pattern_properties, + 'additionalProperties' => false, + ); + + if ( $expected ) { + $this->assertTrue( rest_validate_value_from_schema( $value, $schema ) ); + } else { + $this->assertWPError( rest_validate_value_from_schema( $value, $schema ) ); + } + } + + /** + * @return array + */ + public function data_type_object_pattern_properties() { + return array( + array( array(), array(), true ), + array( array(), array( 'propA' => 'a' ), true ), + array( + array(), + array( + 'propA' => 'a', + 'propB' => 'b', + ), + false, + ), + array( + array( + 'propB' => array( 'type' => 'string' ), + ), + array( 'propA' => 'a' ), + true, + ), + array( + array( + 'propB' => array( 'type' => 'string' ), + ), + array( + 'propA' => 'a', + 'propB' => 'b', + ), + true, + ), + array( + array( + '.*C' => array( 'type' => 'string' ), + ), + array( + 'propA' => 'a', + 'propC' => 'c', + ), + true, + ), + array( + array( + '[0-9]' => array( 'type' => 'integer' ), + ), + array( + 'propA' => 'a', + 'prop0' => 0, + ), + true, + ), + array( + array( + '[0-9]' => array( 'type' => 'integer' ), + ), + array( + 'propA' => 'a', + 'prop0' => 'notAnInteger', + ), + false, + ), + array( + array( + '.+' => array( 'type' => 'string' ), + ), + array( + '' => '', + 'propA' => 'a', + ), + false, + ), + ); + } + public function test_type_object_additional_properties_false() { $schema = array( 'type' => 'object', diff --git a/tests/phpunit/tests/rest-api/rest-test-controller.php b/tests/phpunit/tests/rest-api/rest-test-controller.php index 04732100a4..ab7dbe0cfb 100644 --- a/tests/phpunit/tests/rest-api/rest-test-controller.php +++ b/tests/phpunit/tests/rest-api/rest-test-controller.php @@ -121,6 +121,11 @@ class WP_REST_Test_Controller extends WP_REST_Controller { 'type' => 'integer', ), ), + 'patternProperties' => array( + '[0-9]' => array( + 'type' => 'string', + ), + ), 'minProperties' => 1, 'maxProperties' => 10, 'ignored_prop' => 'ignored_prop',