From 8d1e2214f953666d59f6c23adafa48be16203fec Mon Sep 17 00:00:00 2001 From: Adam Charron Date: Wed, 15 Dec 2021 11:40:20 -0500 Subject: [PATCH 1/3] Allow validating maxByteLength separately from maxLength --- README.md | 339 ++++- src/Schema.php | 2375 ++++++++++++++++++++------------ tests/PropertyTest.php | 1 + tests/StringValidationTest.php | 77 +- 4 files changed, 1815 insertions(+), 977 deletions(-) diff --git a/README.md b/README.md index 67c2bbc..8a1661f 100644 --- a/README.md +++ b/README.md @@ -6,35 +6,35 @@ ![MIT License](https://img.shields.io/packagist/l/vanilla/garden-schema.svg?style=flat) [![CLA](https://cla-assistant.io/readme/badge/vanilla/garden-schema)](https://cla-assistant.io/vanilla/garden-schema) -The Garden Schema is a simple data validation and cleaning library based on [JSON Schema](http://json-schema.org/). +The Garden Schema is a simple data validation and cleaning library based on [OpenAPI 3.0 Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject). ## Features -- Define the data structures of PHP arrays of any depth, and validate them. +- Define the data structures of PHP arrays of any depth, and validate them. -- Validated data is cleaned and coerced into appropriate types. +- Validated data is cleaned and coerced into appropriate types. -- The schema defines a whitelist of allowed data and strips out all extraneous data. +- The schema defines a whitelist of allowed data and strips out all extraneous data. -- The **Schema** class understands data in [JSON Schema](http://json-schema.org/) format. We will add more support for the built-in JSON schema validation as time goes on. +- The **Schema** class understands a subset of data in [OpenAPI Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject) format. We will add more support for the built-in JSON schema validation as time goes on. -- Developers can use a shorter schema format in order to define schemas in code rapidly. We built this class to be as easy to use as possible. Avoid developer groans as they lock down their data. +- Developers can use a shorter schema format in order to define schemas in code rapidly. We built this class to be as easy to use as possible. Avoid developer groans as they lock down their data. -- Add custom validator callbacks to support practically any validation scenario. +- Add custom validator callbacks to support practically any validation scenario. -- Override the validation class in order to customize the way errors are displayed for your own application. +- Override the validation class in order to customize the way errors are displayed for your own application. ## Uses Garden Schema is meant to be a generic wrapper for data validation. It should be valuable when you want to bullet-proof your code against user-submitted data. Here are some example uses: -- Check the data being submitted to your API endpoints. Define the schema at the beginning of your endpoint and validate the data before doing anything else. In this way you can be sure that you are using clean data and avoid a bunch of spaghetti checks later in your code. This was the original reason why we developed the Garden Schema. +- Check the data being submitted to your API endpoints. Define the schema at the beginning of your endpoint and validate the data before doing anything else. In this way you can be sure that you are using clean data and avoid a bunch of spaghetti checks later in your code. This was the original reason why we developed the Garden Schema. -- Clean user input. The Schema object will cast data to appropriate types and gracefully handle common use-cases (ex. converting the string "true" to true for booleans). This allows you to use more "===" checks in your code which helps avoid bugs in the longer term. +- Clean user input. The Schema object will cast data to appropriate types and gracefully handle common use-cases (ex. converting the string "true" to true for booleans). This allows you to use more "===" checks in your code which helps avoid bugs in the longer term. -- Validate data before passing it to the database in order to present human-readable errors rather than cryptic database generated errors. +- Validate data before passing it to the database in order to present human-readable errors rather than cryptic database generated errors. -- Clean output before returning it. A lot of database drivers return data as strings even though it's defined as different types. The Schema will clean the data appropriately which is especially important for consumption by the non-PHP world. +- Clean output before returning it. A lot of database drivers return data as strings even though it's defined as different types. The Schema will clean the data appropriately which is especially important for consumption by the non-PHP world. ## Basic Usage @@ -55,7 +55,7 @@ In the above example a **Schema** object is created with the schema definition p ## Defining Schemas -The **Schema** class is instantiated with an array defining the schema. The array can be in [JSON Schema](http://json-schema.org/) format or it can be in custom short format which is much quicker to write. The short format will be described in this section. +The **Schema** class is instantiated with an array defining the schema. The array can be in [OpenAPI 3.0 Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject) format or it can be in custom short format. It is recommended you define your schemas in the OpenAPI format, but the short format is good for those wanting to write quick prototypes. The short format will be described in this section. By default the schema is an array where each element of the array defines an object property. By "object" we mean javascript object or PHP array with string keys. There are several ways a property can be defined: @@ -78,7 +78,7 @@ By default the schema is an array where each element of the array defines an obj ... ] ] -``` + ``` You can quickly define an object schema by giving just as much information as you need. You can create a schema that is nested as deeply as you want in order to validate very complex data. This short schema is converted into a JSON schema compatible array internally and you can see this array with the **jsonSerialize()** method. @@ -86,18 +86,16 @@ We provide first-class support for descriptions because we believe in writing re ### Types and Short Types -The **Schema** class supports the following types. Each type has a short-form and a long-form. Usually you use the short-form when defining a schema in code and it gets converted to the long-form internally, including when used in errors. +The **Schema** class supports the following types. Each type has one or more aliases. You can use an alias for brevity when defining a schema in code and it gets converted to the proper type internally, including when used in errors. -| Type | Short-form | -| --------- | ---------- | -| boolean | b, bool | -| string | s, str | -| integer | i, int | -| number | f, float | -| timestamp | ts | -| datetime | dt | -| array | a | -| object | o | +Type | Aliases | Notes | +---- | ------- | ----- | +boolean | b, bool | +string | s, str, dt | The "dt" alias adds a format of "date-time" and validates to `DateTimeInterface` instances | +integer | i, int, ts | The "ts" alias adds a format of "timestamp" and will convert date strings into integer timestamps on validation. | +number | f, float | +array | a | +object | o | ### Arrays and Objects @@ -140,29 +138,25 @@ This schema would apply to something like the following data: ] ``` -### Optional Properties and Allow Null +### Optional Properties and Nullable Properties -When defining an object schema you can use a "?" to say that the property is optional. This means that the property can be completely omitted during validation. This is not the same a providing a null value for the property which is considered invalid for optional properties. +When defining an object schema you can use a "?" to say that the property is optional. This means that the property can be completely omitted during validation. This is not the same a providing a **null** value for the property which is considered invalid for optional properties. -If you want a property to allow null values you can specify the **allowNull** attribute on the property. There are two ways to do this: +If you want a property to allow null values you can specify the `nullable` attribute on the property. There are two ways to do this: ```php [ - // You can specify allowNull as a property attribute. - 'opt1:s?' => ['allowNull' => true], + // You can specify nullable as a property attribute. + 'opt1:s?' => ['nullable' => true], // You can specify null as an optional type in the declaration. - 'opt2:s|n?' => 'Another optional property.' + 'opt2:s|n?' => 'Another nullable, optional property.' ] ``` -### Multiple Types - -The type property of the schema can accept an array of types. An array of types means that the data must be any one of the types. - ### Default Values -You can specify a default value on object properties. If the property is omitted during validation then the default value will be used. Note that default values are not applied during sparse validation. +You can specify a default value with the `default` attribute. If the value is omitted during validation then the default value will be used. Note that default values are not applied during sparse validation. ## Validating Data @@ -205,11 +199,177 @@ When you call **validate()** and validation fails a **ValidationException** is t If you are writing an API, you can **json_encode()** the **ValidationException** and it should provide a rich set of data that will help any consumer figure out exactly what they did wrong. You can also use various properties of the **Validation** property to help render the error output appropriately. -### Sparse Validation +#### The Validation JSON Format + +The `Validation` object and `ValidationException` both encode to a [specific format]('./open-api.json'). Here is an example: + +```js +ValidationError = { + "message": "string", // Main error message. + "code": "integer", // HTTP-style status code. + "errors": { // Specific field errors. + "": [ // Each key is a JSON reference field name. + { + "message": "string", // Field error message. + "error": "string", // Specific error code, usually a schema attribute. + "code": "integer" // Optional field error code. + } + ] + } +} +``` + +This format is optimized for helping present errors to user interfaces. You can loop through the specific `errors` collection and line up errors with their inputs on a user interface. For deeply nested objects, the field name is a JSON reference. + +## Schema References + +OpenAPI allows for schemas to be accessed with references using the `$ref` attribute. Using references allows you to define commonly used schemas in one place and then reference them from many locations. + +To use references you must: + +1. Define the schema you want to reference somewhere. +2. Reference the schema with a `$ref` attribute. +3. Add a schema lookup function to your main schema with `Schema::setRefLookp()` + +### Defining a Reusable Schema + +The OpenAPI specification places all reusable schemas under `/components/schemas`. If you are defining everything in a big array that is a good place to put them. + +```php +$components = [ + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'integer' + ], + 'username' => [ + 'type' => 'string' + ] + ] + ] + ] + ] +] +``` + +### Referencing Schemas With `$ref` + +Reference the schema's path with keys separated by `/` characters. + +```php +$userArray = [ + 'type' => 'array', + 'items' => [ + '$ref' => '#/components/schemas/User' + ] +] +``` + +### Using `Schema::setRefLookup()` to Resolve References + +The `Schema` class has a `setRefLookup()` method that lets you add a callable that is use to resolve references. The callable should have the following signature: + +```php +function(string $ref): array|Schema|null { + ... +} +``` + +The function takes the string from the `$ref` attribute and returns a schema array, `Schema` object, or **null** if the schema cannot be found. Garden Schema has a default implementation of a ref lookup in the `ArrayRefLookup` class that can resolve references from a static array. This should be good enough for most uses, but you are always free to define your own. + +You can put everything together like this: + +```php +$sch = new Schema($userArray); +$sch->setRefLookup(new ArrayRefLookup($components)); + +$valid = $sch->validate(...); +``` + +The references are resolved during validation so if there are any mistakes in your references then a `RefNotFoundException` is thrown during validation, not when you set your schema or ref lookup function. + +## Schema Polymorphism + +Schemas have some support for implementing schema polymorphism by letting you validate an object against different schemas depending on its value. + +### The `discriminator` Property + +The `discriminator` of a schema lets you specify an object property that specifies what type of object it is. That property is then used to reference a specific schema for the object. The discriminator has the following format: + +```json5 +{ + "discriminator": { + "propertyName": "", // Name of the property used to reference a schema. + "mapping": { + "": "", // Reference to a schema. + "": "" // Map a value to another value. + } + } +} +``` + +You can see above that the `propertyName` specifies which property is used as the discriminator. There is also an optional `mapping` property that lets you control how schemas are mapped to values. discriminators are resolved int he following way: + +1. The property value is mapped using the mapping property. +2. If the value is a valid JSON reference then it is looked up. Only values in mappings can specify a JSON reference in this way. +3. If the value is not a valid JSON reference then it is is prepended with `#/components/schemas/` to make a JSON reference. + +Here is an example at work: + +```json5 +{ + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "#/components/schemas/Dog", // A direct reference. + "fido": "Dog" // An alias that will be turned into a reference. + } + } +} +``` + +### The `oneOf` Property + +The `oneOf` property works in conjunction with the `discriminator` to limit the schemas that the object is allowed to validate against. If you don't specify `oneOf` then any schemas under `#/components/schemas` are fair game. + +To use the `oneOf` property you must specify `$ref` nodes like so: + +```json5 +{ + "oneOf": [ + { "$ref": "#/components/schemas/Dog" }, + { "$ref": "#/components/schemas/Cat" }, + { "$ref": "#/components/schemas/Mouse" }, + ], + "discriminator": { + "propertyType": "species" + } +} +``` + +In the above example the "species" property will be used to construct a reference to a schema. That reference must match one of the references in the `oneOf` property. + +*If you are familiar with with OpenAPI spec please note that inline schemas are not currently supported for oneOf in Garden Schema.* + + +## Validation Options + +Both **validate()** and **isValid()** can take an additional **$options** argument which modifies the behavior of the validation slightly, depending on the option. + +### The `request` Option + +You can pass an option of `['request' => true]` to specify that you are validating request data. When validating request data, properties that have been marked as `readOnly: true` will be treated as if they don't exist, even if they are marked as required. + +### The `response` Option -Both **validate()** and **isValid()** can take an additional **$sparse** parameter which does a sparse validation if set to true. +You can pass an option of `['response' => true]` to specify that you are validating response data. When validating response data, properties that have been marked as `writeOnly: true` will be treated as if they don't exist, even if they are marked as required. -When you do a sparse validation, missing properties do not give errors and the sparse data is returned. Sparse validation allows you to use the same schema for inserting vs. updating records. This is common in databases or APIs with POST vs. PATCH requests. +### The `sparse` Option + +You can pass an option of `['sparse' => true]` to specify a sparse validation. When you do a sparse validation, missing properties do not give errors and the sparse data is returned. Sparse validation allows you to use the same schema for inserting vs. updating records. This is common in databases or APIs with POST vs. PATCH requests. ## Flags @@ -249,6 +409,44 @@ Set this flag to trigger notices whenever a validated object has properties not Set this flag to throw an exception whenever a validated object has properties not defined in the schema. +## Custom Validation with addValidator() + +You can customize validation with `Schema::addValidator()`. This method lets you attach a callback to a schema path. The callback has the following form: + +```php +function (mixed $value, ValidationField $field): bool { +} +``` + +The callback should `true` if the value is valid or `false` otherwise. You can use the provided `ValidationField` to add custom error messages. + +## Filtering Data + +You can filter data before it is validating using `Schema::addFilter()`. This method lets you filter data at a schema path. The callback has the following form: + +```php +function (mixed $value, ValidationField $field): mixed { +} +``` + +The callback should return the filtered value. Filters are called before validation occurs so you can use them to clean up date you know may need some extra processing. + +The `Schema::addFilter()` also accepts `$validate` parameter that allows your filter to validate the data and bypass default validation. If you are validating date in this way you can add custom errors to the `ValidationField` parameter and return `Invalid::value()` your validation fails. + +### Format Filters + +You can also filter all fields with a particular format using the `Schema::addFormatFilter()`. This method works similar to `Schema::addFilter()` but it applies to all fields that match the given `format`. You can even use format filters to override default format processing. + +```php +$schema = new Schema([...]); + +// By default schema returns instances of DateTimeImmutable, instead return a string. +$schema->addFormatFilter('date-time', function ($v) { + $dt = new \DateTime($v); + return $dt->format(\DateTime::RFC3339); +}, true); +``` + ## Overriding the Validation Class and Localization Since schemas generate error messages, localization may be an issue. Although the Garden Schema doesn't offer any localization capabilities itself, it is designed to be extended in order to add localization yourself. You do this by subclassing the **Validation** class and overriding its **translate()** method. Here is a basic example: @@ -272,30 +470,47 @@ $schema->setValidationClass(LocalizedValidation::class); There are a few things to note in the above example: -- When overriding **translate()** be sure to handle the case where a string starts with the '@' character. Such strings should not be translated and have the character removed. +- When overriding **translate()** be sure to handle the case where a string starts with the '@' character. Such strings should not be translated and have the character removed. -- You tell a **Schema** object to use your specific **Validation** subclass with the **setValidationClass()**. This method takes either a class name or an object instance. If you pass an object it will be cloned every time a validation object is needed. This is good when you want to use dependency injection and your class needs more sophisticated instantiation. +- You tell a **Schema** object to use your specific **Validation** subclass with the **setValidationClass()**. This method takes either a class name or an object instance. If you pass an object it will be cloned every time a validation object is needed. This is good when you want to use dependency injection and your class needs more sophisticated instantiation. ## JSON Schema Support -The **Schema** object is a wrapper for a [JSON Schema](http://json-schema.org/) array. This means that you can pass a valid JSON schema to Schema's constructor. The table below lists the JSON Schema properties that are supported. - -| Property | Type | Notes | -| ----------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| [multipleOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.1) | integer/number | A numeric instance is only valid if division by this keyword's value results in an integer. | -| [maximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.2) | integer/number | If the instance is a number, then this keyword validates only if the instance is less than or exactly equal to "maximum". | -| [exclusiveMaximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.3) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly less than (not equal to) "exclusiveMaximum". | -| [minimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.4) | integer/number | If the instance is a number, then this keyword validates only if the instance is greater than or exactly equal to "minimum". | -| [exclusiveMinimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.5) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly greater than (not equal to) "exclusiveMinimum". | -| [maxLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.6) | string | Limit the unicode character length of a string. | -| [minLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7) | string | Minimum length of a string. | -| [pattern](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.8) | string | A regular expression without delimeters. | -| [items](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.9) | array | Ony supports a single schema. | -| [maxItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.11) | array | Limit the number of items in an array. | -| [minItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.12) | array | Minimum number of items in an array. | -| [required](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.17) | object | Names of required object properties. | -| [properties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.18) | object | Specify schemas for object properties. | -| [enum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.23) | any | Specify an array of valid values. | -| [type](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25) | any | Specify a type of an array of types to validate a value. | -| [default](http://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3) | object | Applies to a schema that is in an object property. | -| [format](http://json-schema.org/latest/json-schema-validation.html#rfc.section.8.3) | string | Support for date-time, email, ipv4, ipv6, ip, uri. | +The **Schema** object is a wrapper for an [OpenAPI Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject) array. This means that you can pass a valid JSON schema to Schema's constructor. The table below lists the JSON Schema properties that are supported. + +| Property | Applies To | Notes | +| -------- | ---------- | ----------- | +| [allOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7.1) | Schema[] | An instance validates successfully against this keyword if it validates successfully against all schemas defined by this keyword's value. | +| [multipleOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.1) | integer/number | A numeric instance is only valid if division by this keyword's value results in an integer. | +| [maximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.2) | integer/number | If the instance is a number, then this keyword validates only if the instance is less than or exactly equal to "maximum". | +| [exclusiveMaximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.3) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly less than (not equal to) "exclusiveMaximum". | +| [minimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.4) | integer/number | If the instance is a number, then this keyword validates only if the instance is greater than or exactly equal to "minimum". | +| [exclusiveMinimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.5) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly greater than (not equal to) "exclusiveMinimum". | +| [maxLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.6) | string | Limit the unicode character length of a string. | +| [minLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7) | string | Minimum length of a string. | +| [pattern](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.8) | string | A regular expression without delimiters. You can add a custom error message with the `x-patternMessageCode` field. | +| [items](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.9) | array | Ony supports a single schema. | +| [maxItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.11) | array | Limit the number of items in an array. | +| [minItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.12) | array | Minimum number of items in an array. | +| [uniqueItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.4.5) | array | All items must be unique. | +| [maxProperties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.5.1) | object | Limit the number of properties on an object. | +| [minProperties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.5.2) | object | Minimum number of properties on an object. | +| [additionalProperties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.5.6) | object | Validate additional properties against a schema. Can also be **true** to always validate. | +| [required](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.17) | object | Names of required object properties. | +| [properties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.18) | object | Specify schemas for object properties. | +| [enum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.23) | any | Specify an array of valid values. | +| [type](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25) | any | Specify a type of an array of types to validate a value. | +| [default](http://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3) | object | Applies to a schema that is in an object property. | +| [format](http://json-schema.org/latest/json-schema-validation.html#rfc.section.8.3) | string | Support for date-time, email, ipv4, ipv6, ip, uri. | +| [oneOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7.3) | object | Works with the `discriminator` property to validate against a dynamic schema. | + +## OpenAPI Schema Support + +OpenAPI defines some extended properties that are applied during validation. + +| Property | Type | Notes | +| -------- | ---- | ----- | +| nullable | boolean | If a field is nullable then it can also take the value **null**. | +| readOnly | boolean | Relevant only for Schema "properties" definitions. Declares the property as "read only". This means that it MAY be sent as part of a response but SHOULD NOT be sent as part of the request. If the property is marked as readOnly being true and is in the required list, the required will take effect on the response only. | +| writeOnly | boolean | Relevant only for Schema "properties" definitions. Declares the property as "write only". Therefore, it MAY be sent as part of a request but SHOULD NOT be sent as part of the response. If the property is marked as writeOnly being true and is in the required list, the required will take effect on the request only. | +| [discriminator](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#discriminatorObject) | object | Validate against a dynamic schema based on a property value. | diff --git a/src/Schema.php b/src/Schema.php index 14abece..02ec199 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -38,9 +38,11 @@ class Schema implements \JsonSerializable, \ArrayAccess { 'string' => ['s', 'str'], 'number' => ['f', 'float'], 'boolean' => ['b', 'bool'], - 'timestamp' => ['ts'], - 'datetime' => ['dt'], - 'null' => ['n'] + + // Psuedo-types + 'timestamp' => ['ts'], // type: integer, format: timestamp + 'datetime' => ['dt'], // type: string, format: date-time + 'null' => ['n'], // Adds nullable: true ]; /** @@ -67,291 +69,42 @@ class Schema implements \JsonSerializable, \ArrayAccess { /** * @var string|Validation The name of the class or an instance that will be cloned. + * @deprecated */ private $validationClass = Validation::class; - - /// Methods /// - - /** - * Initialize an instance of a new {@link Schema} class. - * - * @param array $schema The array schema to validate against. - */ - public function __construct($schema = []) { - $this->schema = $schema; - } - - /** - * Grab the schema's current description. - * - * @return string - */ - public function getDescription() { - return isset($this->schema['description']) ? $this->schema['description'] : ''; - } - - /** - * Set the description for the schema. - * - * @param string $description The new description. - * @throws \InvalidArgumentException Throws an exception when the provided description is not a string. - * @return Schema - */ - public function setDescription($description) { - if (is_string($description)) { - $this->schema['description'] = $description; - } else { - throw new \InvalidArgumentException("The description is not a valid string.", 500); - } - - return $this; - } - - /** - * Get a schema field. - * - * @param string|array $path The JSON schema path of the field with parts separated by dots. - * @param mixed $default The value to return if the field isn't found. - * @return mixed Returns the field value or `$default`. - */ - public function getField($path, $default = null) { - if (is_string($path)) { - $path = explode('.', $path); - } - - $value = $this->schema; - foreach ($path as $i => $subKey) { - if (is_array($value) && isset($value[$subKey])) { - $value = $value[$subKey]; - } elseif ($value instanceof Schema) { - return $value->getField(array_slice($path, $i), $default); - } else { - return $default; - } - } - return $value; - } - - /** - * Set a schema field. - * - * @param string|array $path The JSON schema path of the field with parts separated by dots. - * @param mixed $value The new value. - * @return $this - */ - public function setField($path, $value) { - if (is_string($path)) { - $path = explode('.', $path); - } - - $selection = &$this->schema; - foreach ($path as $i => $subSelector) { - if (is_array($selection)) { - if (!isset($selection[$subSelector])) { - $selection[$subSelector] = []; - } - } elseif ($selection instanceof Schema) { - $selection->setField(array_slice($path, $i), $value); - return $this; - } else { - $selection = [$subSelector => []]; - } - $selection = &$selection[$subSelector]; - } - - $selection = $value; - return $this; - } - - /** - * Get the ID for the schema. - * - * @return string - */ - public function getID() { - return isset($this->schema['id']) ? $this->schema['id'] : ''; - } - - /** - * Set the ID for the schema. - * - * @param string $id The new ID. - * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string. - * @return Schema - */ - public function setID($id) { - if (is_string($id)) { - $this->schema['id'] = $id; - } else { - throw new \InvalidArgumentException("The ID is not a valid string.", 500); - } - - return $this; - } - - /** - * Return the validation flags. - * - * @return int Returns a bitwise combination of flags. - */ - public function getFlags() { - return $this->flags; - } - - /** - * Set the validation flags. - * - * @param int $flags One or more of the **Schema::FLAG_*** constants. - * @return Schema Returns the current instance for fluent calls. - */ - public function setFlags($flags) { - if (!is_int($flags)) { - throw new \InvalidArgumentException('Invalid flags.', 500); - } - $this->flags = $flags; - - return $this; - } - - /** - * Whether or not the schema has a flag (or combination of flags). - * - * @param int $flag One or more of the **Schema::VALIDATE_*** constants. - * @return bool Returns **true** if all of the flags are set or **false** otherwise. - */ - public function hasFlag($flag) { - return ($this->flags & $flag) === $flag; - } - /** - * Set a flag. - * - * @param int $flag One or more of the **Schema::VALIDATE_*** constants. - * @param bool $value Either true or false. - * @return $this + * @var callable A callback is used to create validation objects. */ - public function setFlag($flag, $value) { - if ($value) { - $this->flags = $this->flags | $flag; - } else { - $this->flags = $this->flags & ~$flag; - } - return $this; - } + private $validationFactory = [Validation::class, 'createValidation']; /** - * Merge a schema with this one. - * - * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance. - * @return $this + * @var callable */ - public function merge(Schema $schema) { - $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true); - return $this; - } + private $refLookup; - /** - * Add another schema to this one. - * - * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information. - * - * @param Schema $schema The schema to add. - * @param bool $addProperties Whether to add properties that don't exist in this schema. - * @return $this - */ - public function add(Schema $schema, $addProperties = false) { - $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties); - return $this; - } + /// Methods /// /** - * The internal implementation of schema merging. + * Initialize an instance of a new {@link Schema} class. * - * @param array &$target The target of the merge. - * @param array $source The source of the merge. - * @param bool $overwrite Whether or not to replace values. - * @param bool $addProperties Whether or not to add object properties to the target. - * @return array + * @param array $schema The array schema to validate against. + * @param callable $refLookup The function used to lookup references. */ - private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) { - // We need to do a fix for required properties here. - if (isset($target['properties']) && !empty($source['required'])) { - $required = isset($target['required']) ? $target['required'] : []; - - if (isset($source['required']) && $addProperties) { - $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties'])); - $newRequired = array_intersect($source['required'], $newProperties); - - $required = array_merge($required, $newRequired); - } - } - - - foreach ($source as $key => $val) { - if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) { - if ($key === 'properties' && !$addProperties) { - // We just want to merge the properties that exist in the destination. - foreach ($val as $name => $prop) { - if (isset($target[$key][$name])) { - $targetProp = &$target[$key][$name]; - - if (is_array($targetProp) && is_array($prop)) { - $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties); - } elseif (is_array($targetProp) && $prop instanceof Schema) { - $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties); - } elseif ($overwrite) { - $targetProp = $prop; - } - } - } - } elseif (isset($val[0]) || isset($target[$key][0])) { - if ($overwrite) { - // This is a numeric array, so just do a merge. - $merged = array_merge($target[$key], $val); - if (is_string($merged[0])) { - $merged = array_keys(array_flip($merged)); - } - $target[$key] = $merged; - } - } else { - $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties); - } - } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) { - // Do nothing, we aren't replacing. - } else { - $target[$key] = $val; - } - } - - if (isset($required)) { - if (empty($required)) { - unset($target['required']); - } else { - $target['required'] = $required; - } - } - - return $target; - } - -// public function overlay(Schema $schema ) + public function __construct(array $schema = [], callable $refLookup = null) { + $this->schema = $schema; - /** - * Returns the internal schema array. - * - * @return array - * @see Schema::jsonSerialize() - */ - public function getSchemaArray() { - return $this->schema; + $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */ + string $_) { + return null; + }; } /** * Parse a short schema and return the associated schema. * * @param array $arr The schema array. - * @param mixed ...$args Constructor arguments for the schema instance. + * @param mixed[] $args Constructor arguments for the schema instance. * @return static Returns a new schema. */ public static function parse(array $arr, ...$args) { @@ -365,9 +118,9 @@ public static function parse(array $arr, ...$args) { * * @param array $arr The array to parse into a schema. * @return array The full schema array. - * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid. + * @throws ParseException Throws an exception when an item in the schema is invalid. */ - protected function parseInternal(array $arr) { + protected function parseInternal(array $arr): array { if (empty($arr)) { // An empty schema validates to anything. return []; @@ -403,12 +156,17 @@ protected function parseInternal(array $arr) { /** * Parse a schema node. * - * @param array $node The node to parse. + * @param array|Schema $node The node to parse. * @param mixed $value Additional information from the node. - * @return array Returns a JSON schema compatible node. + * @return array|\ArrayAccess Returns a JSON schema compatible node. + * @throws ParseException Throws an exception if there was a problem parsing the schema node. */ private function parseNode($node, $value = null) { if (is_array($value)) { + if (is_array($node['type'])) { + trigger_error('Schemas with multiple types are deprecated.', E_USER_DEPRECATED); + } + // The value describes a bit more about the schema. switch ($node['type']) { case 'array': @@ -447,13 +205,12 @@ private function parseNode($node, $value = null) { $node['items'] = $this->parseInternal($node['items']); } elseif ($node['type'] === 'object' && isset($node['properties'])) { list($node['properties']) = $this->parseProperties($node['properties']); - } } if (is_array($node)) { if (!empty($node['allowNull'])) { - $node['type'] = array_merge((array)$node['type'], ['null']); + $node['nullable'] = true; } unset($node['allowNull']); @@ -470,8 +227,9 @@ private function parseNode($node, $value = null) { * * @param array $arr An object property schema. * @return array Returns a schema array suitable to be placed in the **properties** key of a schema. + * @throws ParseException Throws an exception if a property name cannot be determined for an array item. */ - private function parseProperties(array $arr) { + private function parseProperties(array $arr): array { $properties = []; $requiredProperties = []; foreach ($arr as $key => $value) { @@ -481,7 +239,7 @@ private function parseProperties(array $arr) { $key = $value; $value = ''; } else { - throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500); + throw new ParseException("Schema at position $key is not a valid parameter.", 500); } } @@ -495,7 +253,7 @@ private function parseProperties(array $arr) { $requiredProperties[] = $name; } } - return array($properties, $requiredProperties); + return [$properties, $requiredProperties]; } /** @@ -504,9 +262,9 @@ private function parseProperties(array $arr) { * @param string $key The short parameter string to parse. * @param array $value An array of other information that might help resolve ambiguity. * @return array Returns an array in the form `[string name, array param, bool required]`. - * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format. + * @throws ParseException Throws an exception if the short param is not in the correct format. */ - public function parseShortParam($key, $value = []) { + public function parseShortParam(string $key, $value = []): array { // Is the parameter optional? if (substr($key, -1) === '?') { $required = false; @@ -516,16 +274,36 @@ public function parseShortParam($key, $value = []) { } // Check for a type. - $parts = explode(':', $key); - $name = $parts[0]; + if (false !== ($pos = strrpos($key, ':'))) { + $name = substr($key, 0, $pos); + $typeStr = substr($key, $pos + 1); + + // Kludge for names with colons that are not specifying an array of a type. + if (isset($value['type']) && 'array' !== $this->getType($typeStr)) { + $name = $key; + $typeStr = ''; + } + } else { + $name = $key; + $typeStr = ''; + } $types = []; + $param = []; - if (!empty($parts[1])) { - $shortTypes = explode('|', $parts[1]); + if (!empty($typeStr)) { + $shortTypes = explode('|', $typeStr); foreach ($shortTypes as $alias) { $found = $this->getType($alias); if ($found === null) { - throw new \InvalidArgumentException("Unknown type '$alias'", 500); + throw new ParseException("Unknown type '$alias'.", 500); + } elseif ($found === 'datetime') { + $param['format'] = 'date-time'; + $types[] = 'string'; + } elseif ($found === 'timestamp') { + $param['format'] = 'timestamp'; + $types[] = 'integer'; + } elseif ($found === 'null') { + $nullable = true; } else { $types[] = $found; } @@ -534,27 +312,27 @@ public function parseShortParam($key, $value = []) { if ($value instanceof Schema) { if (count($types) === 1 && $types[0] === 'array') { - $param = ['type' => $types[0], 'items' => $value]; + $param += ['type' => $types[0], 'items' => $value]; } else { $param = $value; } } elseif (isset($value['type'])) { - $param = $value; + $param = $value + $param; if (!empty($types) && $types !== (array)$param['type']) { $typesStr = implode('|', $types); $paramTypesStr = implode('|', (array)$param['type']); - throw new \InvalidArgumentException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500); + throw new ParseException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500); } } else { if (empty($types) && !empty($parts[1])) { - throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500); + throw new ParseException("Invalid type {$parts[1]} for field $name.", 500); } if (empty($types)) { - $param = ['type' => null]; + $param += ['type' => null]; } else { - $param = ['type' => count($types) === 1 ? $types[0] : $types]; + $param += ['type' => count($types) === 1 ? $types[0] : $types]; } // Parsed required strings have a minimum length of 1. @@ -563,181 +341,1190 @@ public function parseShortParam($key, $value = []) { } } + if (!empty($nullable)) { + $param['nullable'] = true; + } + + if (is_array($param['type'])) { + trigger_error('Schemas with multiple types is deprecated.', E_USER_DEPRECATED); + } + return [$name, $param, $required]; } /** - * Add a custom filter to change data before validation. - * - * @param string $fieldname The name of the field to filter, if any. + * Look up a type based on its alias. * - * If you are adding a filter to a deeply nested field then separate the path with dots. + * @param string $alias The type alias or type name to lookup. + * @return mixed + */ + private function getType($alias) { + if (isset(self::$types[$alias])) { + return $alias; + } + foreach (self::$types as $type => $aliases) { + if (in_array($alias, $aliases, true)) { + return $type; + } + } + return null; + } + + /** + * Unescape a JSON reference segment. + * + * @param string $str The segment to unescapeRef. + * @return string Returns the unescaped string. + */ + public static function unescapeRef(string $str): string { + return str_replace(['~1', '~0'], ['/', '~'], $str); + } + + /** + * Explode a references into its individual parts. + * + * @param string $ref A JSON reference. + * @return string[] The individual parts of the reference. + */ + public static function explodeRef(string $ref): array { + return array_map([self::class, 'unescapeRef'], explode('/', $ref)); + } + + /** + * Grab the schema's current description. + * + * @return string + */ + public function getDescription(): string { + return $this->schema['description'] ?? ''; + } + + /** + * Set the description for the schema. + * + * @param string $description The new description. + * @return $this + */ + public function setDescription(string $description) { + $this->schema['description'] = $description; + return $this; + } + + /** + * Get the schema's title. + * + * @return string Returns the title. + */ + public function getTitle(): string { + return $this->schema['title'] ?? ''; + } + + /** + * Set the schema's title. + * + * @param string $title The new title. + */ + public function setTitle(string $title) { + $this->schema['title'] = $title; + } + + /** + * Get a schema field. + * + * @param string|array $path The JSON schema path of the field with parts separated by dots. + * @param mixed $default The value to return if the field isn't found. + * @return mixed Returns the field value or `$default`. + */ + public function getField($path, $default = null) { + if (is_string($path)) { + if (strpos($path, '.') !== false && strpos($path, '/') === false) { + trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED); + $path = explode('.', $path); + } else { + $path = explode('/', $path); + } + } + + $value = $this->schema; + foreach ($path as $i => $subKey) { + if (is_array($value) && isset($value[$subKey])) { + $value = $value[$subKey]; + } elseif ($value instanceof Schema) { + return $value->getField(array_slice($path, $i), $default); + } else { + return $default; + } + } + return $value; + } + + /** + * Set a schema field. + * + * @param string|array $path The JSON schema path of the field with parts separated by slashes. + * @param mixed $value The new value. + * @return $this + */ + public function setField($path, $value) { + if (is_string($path)) { + if (strpos($path, '.') !== false && strpos($path, '/') === false) { + trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED); + $path = explode('.', $path); + } else { + $path = explode('/', $path); + } + } + + $selection = &$this->schema; + foreach ($path as $i => $subSelector) { + if (is_array($selection)) { + if (!isset($selection[$subSelector])) { + $selection[$subSelector] = []; + } + } elseif ($selection instanceof Schema) { + $selection->setField(array_slice($path, $i), $value); + return $this; + } else { + $selection = [$subSelector => []]; + } + $selection = &$selection[$subSelector]; + } + + $selection = $value; + return $this; + } + + /** + * Return the validation flags. + * + * @return int Returns a bitwise combination of flags. + */ + public function getFlags(): int { + return $this->flags; + } + + /** + * Set the validation flags. + * + * @param int $flags One or more of the **Schema::FLAG_*** constants. + * @return Schema Returns the current instance for fluent calls. + */ + public function setFlags(int $flags) { + $this->flags = $flags; + + return $this; + } + + /** + * Set a flag. + * + * @param int $flag One or more of the **Schema::VALIDATE_*** constants. + * @param bool $value Either true or false. + * @return $this + */ + public function setFlag(int $flag, bool $value) { + if ($value) { + $this->flags = $this->flags | $flag; + } else { + $this->flags = $this->flags & ~$flag; + } + return $this; + } + + /** + * Merge a schema with this one. + * + * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance. + * @return $this + */ + public function merge(Schema $schema) { + $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true); + return $this; + } + + /** + * The internal implementation of schema merging. + * + * @param array $target The target of the merge. + * @param array $source The source of the merge. + * @param bool $overwrite Whether or not to replace values. + * @param bool $addProperties Whether or not to add object properties to the target. + * @return array + */ + private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) { + // We need to do a fix for required properties here. + if (isset($target['properties']) && !empty($source['required'])) { + $required = isset($target['required']) ? $target['required'] : []; + + if (isset($source['required']) && $addProperties) { + $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties'])); + $newRequired = array_intersect($source['required'], $newProperties); + + $required = array_merge($required, $newRequired); + } + } + + + foreach ($source as $key => $val) { + if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) { + if ($key === 'properties' && !$addProperties) { + // We just want to merge the properties that exist in the destination. + foreach ($val as $name => $prop) { + if (isset($target[$key][$name])) { + $targetProp = &$target[$key][$name]; + + if (is_array($targetProp) && is_array($prop)) { + $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties); + } elseif (is_array($targetProp) && $prop instanceof Schema) { + $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties); + } elseif ($overwrite) { + $targetProp = $prop; + } + } + } + } elseif (isset($val[0]) || isset($target[$key][0])) { + if ($overwrite) { + // This is a numeric array, so just do a merge. + $merged = array_merge($target[$key], $val); + if (is_string($merged[0])) { + $merged = array_keys(array_flip($merged)); + } + $target[$key] = $merged; + } + } else { + $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties); + } + } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) { + // Do nothing, we aren't replacing. + } else { + $target[$key] = $val; + } + } + + if (isset($required)) { + if (empty($required)) { + unset($target['required']); + } else { + $target['required'] = $required; + } + } + + return $target; + } + + /** + * Returns the internal schema array. + * + * @return array + * @see Schema::jsonSerialize() + */ + public function getSchemaArray(): array { + return $this->schema; + } + + /** + * Add another schema to this one. + * + * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information. + * + * @param Schema $schema The schema to add. + * @param bool $addProperties Whether to add properties that don't exist in this schema. + * @return $this + */ + public function add(Schema $schema, $addProperties = false) { + $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties); + return $this; + } + + /** + * Add a custom filter to change data before validation. + * + * @param string $fieldname The name of the field to filter, if any. + * + * If you are adding a filter to a deeply nested field then separate the path with dots. * @param callable $callback The callback to filter the field. + * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped. * @return $this */ - public function addFilter($fieldname, callable $callback) { - $this->filters[$fieldname][] = $callback; + public function addFilter(string $fieldname, callable $callback, bool $validate = false) { + $fieldname = $this->parseFieldSelector($fieldname); + $this->filters[$fieldname][] = [$callback, $validate]; return $this; } /** - * Add a custom validator to to validate the schema. - * - * @param string $fieldname The name of the field to validate, if any. + * Parse a nested field name selector. + * + * Field selectors should be separated by "/" characters, but may currently be separated by "." characters which + * triggers a deprecated error. + * + * @param string $field The field selector. + * @return string Returns the field selector in the correct format. + */ + private function parseFieldSelector(string $field): string { + if (strlen($field) === 0) { + return $field; + } + + if (strpos($field, '.') !== false) { + if (strpos($field, '/') === false) { + trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED); + + $parts = explode('.', $field); + $parts = @array_map([$this, 'parseFieldSelector'], $parts); // silence because error triggered already. + + $field = implode('/', $parts); + } + } elseif ($field === '[]') { + trigger_error('Field selectors with item selector "[]" must be converted to "items".', E_USER_DEPRECATED); + $field = 'items'; + } elseif (strpos($field, '/') === false && !in_array($field, ['items', 'additionalProperties'], true)) { + trigger_error("Field selectors must specify full schema paths. ($field)", E_USER_DEPRECATED); + $field = "/properties/$field"; + } + + if (strpos($field, '[]') !== false) { + trigger_error('Field selectors with item selector "[]" must be converted to "/items".', E_USER_DEPRECATED); + $field = str_replace('[]', '/items', $field); + } + + return ltrim($field, '/'); + } + + /** + * Add a custom filter for a schema format. + * + * Schemas can use the `format` property to specify a specific format on a field. Adding a filter for a format + * allows you to customize the behavior of that format. + * + * @param string $format The format to filter. + * @param callable $callback The callback used to filter values. + * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped. + * @return $this + */ + public function addFormatFilter(string $format, callable $callback, bool $validate = false) { + if (empty($format)) { + throw new \InvalidArgumentException('The filter format cannot be empty.', 500); + } + + $filter = "/format/$format"; + $this->filters[$filter][] = [$callback, $validate]; + + return $this; + } + + /** + * Require one of a given set of fields in the schema. + * + * @param array $required The field names to require. + * @param string $fieldname The name of the field to attach to. + * @param int $count The count of required items. + * @return Schema Returns `$this` for fluent calls. + */ + public function requireOneOf(array $required, string $fieldname = '', int $count = 1) { + $result = $this->addValidator( + $fieldname, + function ($data, ValidationField $field) use ($required, $count) { + // This validator does not apply to sparse validation. + if ($field->isSparse()) { + return true; + } + + $hasCount = 0; + $flattened = []; + + foreach ($required as $name) { + $flattened = array_merge($flattened, (array)$name); + + if (is_array($name)) { + // This is an array of required names. They all must match. + $hasCountInner = 0; + foreach ($name as $nameInner) { + if (array_key_exists($nameInner, $data)) { + $hasCountInner++; + } else { + break; + } + } + if ($hasCountInner >= count($name)) { + $hasCount++; + } + } elseif (array_key_exists($name, $data)) { + $hasCount++; + } + + if ($hasCount >= $count) { + return true; + } + } + + if ($count === 1) { + $message = 'One of {properties} are required.'; + } else { + $message = '{count} of {properties} are required.'; + } + + $field->addError('oneOfRequired', [ + 'messageCode' => $message, + 'properties' => $required, + 'count' => $count + ]); + return false; + } + ); + + return $result; + } + + /** + * Add a custom validator to to validate the schema. + * + * @param string $fieldname The name of the field to validate, if any. + * + * If you are adding a validator to a deeply nested field then separate the path with dots. + * @param callable $callback The callback to validate with. + * @return Schema Returns `$this` for fluent calls. + */ + public function addValidator(string $fieldname, callable $callback) { + $fieldname = $this->parseFieldSelector($fieldname); + $this->validators[$fieldname][] = $callback; + return $this; + } + + /** + * Validate data against the schema and return the result. + * + * @param mixed $data The data to validate. + * @param array $options Validation options. See `Schema::validate()`. + * @return bool Returns true if the data is valid. False otherwise. + * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema. + */ + public function isValid($data, $options = []) { + try { + $this->validate($data, $options); + return true; + } catch (ValidationException $ex) { + return false; + } + } + + /** + * Validate data against the schema. + * + * @param mixed $data The data to validate. + * @param array $options Validation options. + * + * - **sparse**: Whether or not this is a sparse validation. + * @return mixed Returns a cleaned version of the data. + * @throws ValidationException Throws an exception when the data does not validate against the schema. + * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. + */ + public function validate($data, $options = []) { + if (is_bool($options)) { + trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED); + $options = ['sparse' => true]; + } + $options += ['sparse' => false]; + + + list($schema, $schemaPath) = $this->lookupSchema($this->schema, ''); + $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options); + + $clean = $this->validateField($data, $field); + + if (Invalid::isInvalid($clean) && $field->isValid()) { + // This really shouldn't happen, but we want to protect against seeing the invalid object. + $field->addError('invalid', ['messageCode' => 'The value is invalid.']); + } + + if (!$field->getValidation()->isValid()) { + throw new ValidationException($field->getValidation()); + } + + return $clean; + } + + /** + * Lookup a schema based on a schema node. + * + * The node could be a schema array, `Schema` object, or a schema reference. + * + * @param mixed $schema The schema node to lookup with. + * @param string $schemaPath The current path of the schema. + * @return array Returns an array with two elements: + * - Schema|array|\ArrayAccess The schema that was found. + * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas. + * @throws RefNotFoundException Throws an exception when a reference could not be found. + */ + private function lookupSchema($schema, string $schemaPath) { + if ($schema instanceof Schema) { + return [$schema, $schemaPath]; + } else { + $lookup = $this->getRefLookup(); + $visited = []; + + // Resolve any references first. + while (!empty($schema['$ref'])) { + $schemaPath = $schema['$ref']; + + if (isset($visited[$schemaPath])) { + throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508); + } + $visited[$schemaPath] = true; + + try { + $schema = call_user_func($lookup, $schemaPath); + } catch (\Exception $ex) { + throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex); + } + if ($schema === null) { + throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)"); + } + } + + return [$schema, $schemaPath]; + } + } + + /** + * Get the function used to resolve `$ref` lookups. + * + * @return callable Returns the current `$ref` lookup. + */ + public function getRefLookup(): callable { + return $this->refLookup; + } + + /** + * Set the function used to resolve `$ref` lookups. + * + * The function should have the following signature: + * + * ```php + * function(string $ref): array|Schema|null { + * ... + * } + * ``` + * The function should take a string reference and return a schema array, `Schema` or **null**. + * + * @param callable $refLookup The new lookup function. + * @return $this + */ + public function setRefLookup(callable $refLookup) { + $this->refLookup = $refLookup; + return $this; + } + + /** + * Create a new validation instance. + * + * @return Validation Returns a validation object. + */ + protected function createValidation(): Validation { + return call_user_func($this->getValidationFactory()); + } + + /** + * Get factory used to create validation objects. + * + * @return callable Returns the current factory. + */ + public function getValidationFactory(): callable { + return $this->validationFactory; + } + + /** + * Set the factory used to create validation objects. + * + * @param callable $validationFactory The new factory. + * @return $this + */ + public function setValidationFactory(callable $validationFactory) { + $this->validationFactory = $validationFactory; + $this->validationClass = null; + return $this; + } + + /** + * Validate a field. + * + * @param mixed $value The value to validate. + * @param ValidationField $field A validation object to add errors to. + * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value + * is completely invalid. + * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. + */ + protected function validateField($value, ValidationField $field) { + $validated = false; + $result = $value = $this->filterField($value, $field, $validated); + + if ($validated) { + return $result; + } elseif ($field->getField() instanceof Schema) { + try { + $result = $field->getField()->validate($value, $field->getOptions()); + } catch (ValidationException $ex) { + // The validation failed, so merge the validations together. + $field->getValidation()->merge($ex->getValidation(), $field->getName()); + } + } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && ($field->val('nullable') || $field->hasType('null'))) { + $result = null; + } else { + // Look for a discriminator. + if (!empty($field->val('discriminator'))) { + $field = $this->resolveDiscriminator($value, $field); + } + + if ($field !== null) { + if($field->hasAllOf()) { + $result = $this->validateAllOf($value, $field); + } else { + // Validate the field's type. + $type = $field->getType(); + if (is_array($type)) { + $result = $this->validateMultipleTypes($value, $type, $field); + } else { + $result = $this->validateSingleType($value, $type, $field); + } + + if (Invalid::isValid($result)) { + $result = $this->validateEnum($result, $field); + } + } + } else { + $result = Invalid::value(); + } + } + + // Validate a custom field validator. + if (Invalid::isValid($result)) { + $this->callValidators($result, $field); + } + + return $result; + } + + /** + * Filter a field's value using built in and custom filters. + * + * @param mixed $value The original value of the field. + * @param ValidationField $field The field information for the field. + * @param bool $validated Whether or not a filter validated the value. + * @return mixed Returns the filtered field or the original field value if there are no filters. + */ + private function filterField($value, ValidationField $field, bool &$validated = false) { + // Check for limited support for Open API style. + if (!empty($field->val('style')) && is_string($value)) { + $doFilter = true; + if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) { + $doFilter = false; + } elseif (($field->hasType('integer') || $field->hasType('number')) && is_numeric($value)) { + $doFilter = false; + } + + if ($doFilter) { + switch ($field->val('style')) { + case 'form': + $value = explode(',', $value); + break; + case 'spaceDelimited': + $value = explode(' ', $value); + break; + case 'pipeDelimited': + $value = explode('|', $value); + break; + } + } + } + + $value = $this->callFilters($value, $field, $validated); + + return $value; + } + + /** + * Call all of the filters attached to a field. + * + * @param mixed $value The field value being filtered. + * @param ValidationField $field The validation object. + * @param bool $validated Whether or not a filter validated the field. + * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned. + */ + private function callFilters($value, ValidationField $field, bool &$validated = false) { + // Strip array references in the name except for the last one. + $key = $field->getSchemaPath(); + if (!empty($this->filters[$key])) { + foreach ($this->filters[$key] as list($filter, $validate)) { + $value = call_user_func($filter, $value, $field); + $validated |= $validate; + + if (Invalid::isInvalid($value)) { + return $value; + } + } + } + $key = '/format/'.$field->val('format'); + if (!empty($this->filters[$key])) { + foreach ($this->filters[$key] as list($filter, $validate)) { + $value = call_user_func($filter, $value, $field); + $validated |= $validate; + + if (Invalid::isInvalid($value)) { + return $value; + } + } + } + + return $value; + } + + /** + * Validate a field against multiple basic types. + * + * The first validation that passes will be returned. If no type can be validated against then validation will fail. + * + * @param mixed $value The value to validate. + * @param string[] $types The types to validate against. + * @param ValidationField $field Contains field and validation information. + * @return mixed Returns the valid value or `Invalid`. + * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. + * @deprecated Multiple types are being removed next version. + */ + private function validateMultipleTypes($value, array $types, ValidationField $field) { + trigger_error('Multiple schema types are deprecated.', E_USER_DEPRECATED); + + // First check for an exact type match. + switch (gettype($value)) { + case 'boolean': + if (in_array('boolean', $types)) { + $singleType = 'boolean'; + } + break; + case 'integer': + if (in_array('integer', $types)) { + $singleType = 'integer'; + } elseif (in_array('number', $types)) { + $singleType = 'number'; + } + break; + case 'double': + if (in_array('number', $types)) { + $singleType = 'number'; + } elseif (in_array('integer', $types)) { + $singleType = 'integer'; + } + break; + case 'string': + if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) { + $singleType = 'datetime'; + } elseif (in_array('string', $types)) { + $singleType = 'string'; + } + break; + case 'array': + if (in_array('array', $types) && in_array('object', $types)) { + $singleType = isset($value[0]) || empty($value) ? 'array' : 'object'; + } elseif (in_array('object', $types)) { + $singleType = 'object'; + } elseif (in_array('array', $types)) { + $singleType = 'array'; + } + break; + case 'NULL': + if (in_array('null', $types)) { + $singleType = $this->validateSingleType($value, 'null', $field); + } + break; + } + if (!empty($singleType)) { + return $this->validateSingleType($value, $singleType, $field); + } + + // Clone the validation field to collect errors. + $typeValidation = new ValidationField(new Validation(), $field->getField(), '', '', $field->getOptions()); + + // Try and validate against each type. + foreach ($types as $type) { + $result = $this->validateSingleType($value, $type, $typeValidation); + if (Invalid::isValid($result)) { + return $result; + } + } + + // Since we got here the value is invalid. + $field->merge($typeValidation->getValidation()); + return Invalid::value(); + } + + /** + * Validate a field against a single type. + * + * @param mixed $value The value to validate. + * @param string $type The type to validate against. + * @param ValidationField $field Contains field and validation information. + * @return mixed Returns the valid value or `Invalid`. + * @throws \InvalidArgumentException Throws an exception when `$type` is not recognized. + * @throws RefNotFoundException Throws an exception when internal validation has a reference that isn't found. + */ + protected function validateSingleType($value, string $type, ValidationField $field) { + switch ($type) { + case 'boolean': + $result = $this->validateBoolean($value, $field); + break; + case 'integer': + $result = $this->validateInteger($value, $field); + break; + case 'number': + $result = $this->validateNumber($value, $field); + break; + case 'string': + $result = $this->validateString($value, $field); + break; + case 'timestamp': + trigger_error('The timestamp type is deprecated. Use an integer with a format of timestamp instead.', E_USER_DEPRECATED); + $result = $this->validateTimestamp($value, $field); + break; + case 'datetime': + trigger_error('The datetime type is deprecated. Use a string with a format of date-time instead.', E_USER_DEPRECATED); + $result = $this->validateDatetime($value, $field); + break; + case 'array': + $result = $this->validateArray($value, $field); + break; + case 'object': + $result = $this->validateObject($value, $field); + break; + case 'null': + $result = $this->validateNull($value, $field); + break; + case '': + // No type was specified so we are valid. + $result = $value; + break; + default: + throw new \InvalidArgumentException("Unrecognized type $type.", 500); + } + return $result; + } + + /** + * Validate a boolean value. + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return bool|Invalid Returns the cleaned value or invalid if validation fails. + */ + protected function validateBoolean($value, ValidationField $field) { + $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + $field->addTypeError($value, 'boolean'); + return Invalid::value(); + } + + return $value; + } + + /** + * Validate and integer. + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return int|Invalid Returns the cleaned value or **null** if validation fails. + */ + protected function validateInteger($value, ValidationField $field) { + if ($field->val('format') === 'timestamp') { + return $this->validateTimestamp($value, $field); + } + + $result = filter_var($value, FILTER_VALIDATE_INT); + + if ($result === false) { + $field->addTypeError($value, 'integer'); + return Invalid::value(); + } + + $result = $this->validateNumberProperties($result, $field); + + return $result; + } + + /** + * Validate a unix timestamp. * - * If you are adding a validator to a deeply nested field then separate the path with dots. - * @param callable $callback The callback to validate with. - * @return Schema Returns `$this` for fluent calls. + * @param mixed $value The value to validate. + * @param ValidationField $field The field being validated. + * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate. */ - public function addValidator($fieldname, callable $callback) { - $this->validators[$fieldname][] = $callback; - return $this; + protected function validateTimestamp($value, ValidationField $field) { + if (is_numeric($value) && $value > 0) { + $result = (int)$value; + } elseif (is_string($value) && $ts = strtotime($value)) { + $result = $ts; + } else { + $field->addTypeError($value, 'timestamp'); + $result = Invalid::value(); + } + return $result; } /** - * Require one of a given set of fields in the schema. + * Validate specific numeric validation properties. * - * @param array $required The field names to require. - * @param string $fieldname The name of the field to attach to. - * @param int $count The count of required items. - * @return Schema Returns `$this` for fluent calls. + * @param int|float $value The value to test. + * @param ValidationField $field Field information. + * @return int|float|Invalid Returns the number of invalid. */ - public function requireOneOf(array $required, $fieldname = '', $count = 1) { - $result = $this->addValidator( - $fieldname, - function ($data, ValidationField $field) use ($required, $count) { - // This validator does not apply to sparse validation. - if ($field->isSparse()) { - return true; - } + private function validateNumberProperties($value, ValidationField $field) { + $count = $field->getErrorCount(); - $hasCount = 0; - $flattened = []; + if ($multipleOf = $field->val('multipleOf')) { + $divided = $value / $multipleOf; - foreach ($required as $name) { - $flattened = array_merge($flattened, (array)$name); + if ($divided != round($divided)) { + $field->addError('multipleOf', ['messageCode' => 'The value must be a multiple of {multipleOf}.', 'multipleOf' => $multipleOf]); + } + } - if (is_array($name)) { - // This is an array of required names. They all must match. - $hasCountInner = 0; - foreach ($name as $nameInner) { - if (array_key_exists($nameInner, $data)) { - $hasCountInner++; - } else { - break; - } - } - if ($hasCountInner >= count($name)) { - $hasCount++; - } - } elseif (array_key_exists($name, $data)) { - $hasCount++; - } + if ($maximum = $field->val('maximum')) { + $exclusive = $field->val('exclusiveMaximum'); - if ($hasCount >= $count) { - return true; - } + if ($value > $maximum || ($exclusive && $value == $maximum)) { + if ($exclusive) { + $field->addError('maximum', ['messageCode' => 'The value must be less than {maximum}.', 'maximum' => $maximum]); + } else { + $field->addError('maximum', ['messageCode' => 'The value must be less than or equal to {maximum}.', 'maximum' => $maximum]); } + } + } - if ($count === 1) { - $message = 'One of {required} are required.'; + if ($minimum = $field->val('minimum')) { + $exclusive = $field->val('exclusiveMinimum'); + + if ($value < $minimum || ($exclusive && $value == $minimum)) { + if ($exclusive) { + $field->addError('minimum', ['messageCode' => 'The value must be greater than {minimum}.', 'minimum' => $minimum]); } else { - $message = '{count} of {required} are required.'; + $field->addError('minimum', ['messageCode' => 'The value must be greater than or equal to {minimum}.', 'minimum' => $minimum]); } - - $field->addError('missingField', [ - 'messageCode' => $message, - 'required' => $required, - 'count' => $count - ]); - return false; } - ); + } + + return $field->getErrorCount() === $count ? $value : Invalid::value(); + } + + /** + * Validate a float. + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return float|Invalid Returns a number or **null** if validation fails. + */ + protected function validateNumber($value, ValidationField $field) { + $result = filter_var($value, FILTER_VALIDATE_FLOAT); + if ($result === false) { + $field->addTypeError($value, 'number'); + return Invalid::value(); + } + + $result = $this->validateNumberProperties($result, $field); return $result; } /** - * Validate data against the schema. + * Validate a string. * - * @param mixed $data The data to validate. - * @param bool $sparse Whether or not this is a sparse validation. - * @return mixed Returns a cleaned version of the data. - * @throws ValidationException Throws an exception when the data does not validate against the schema. + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return string|Invalid Returns the valid string or **null** if validation fails. */ - public function validate($data, $sparse = false) { - $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse); + protected function validateString($value, ValidationField $field) { + if ($field->val('format') === 'date-time') { + $result = $this->validateDatetime($value, $field); + return $result; + } - $clean = $this->validateField($data, $field, $sparse); + if (is_string($value) || is_numeric($value)) { + $value = $result = (string)$value; + } else { + $field->addTypeError($value, 'string'); + return Invalid::value(); + } - if (Invalid::isInvalid($clean) && $field->isValid()) { - // This really shouldn't happen, but we want to protect against seeing the invalid object. - $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]); + $strFn = $this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE) ? "mb_strlen" : "strlen"; + $strLen = $strFn($value); + if (($minLength = $field->val('minLength', 0)) > 0 && $strLen < $minLength) { + $field->addError( + 'minLength', + [ + 'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.', + 'minLength' => $minLength, + ] + ); } - if (!$field->getValidation()->isValid()) { - throw new ValidationException($field->getValidation()); + if (($maxLength = $field->val('maxLength', 0)) > 0 && $strLen > $maxLength) { + $field->addError( + 'maxLength', + [ + 'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.', + 'maxLength' => $maxLength, + 'overflow' => $strLen - $maxLength, + ] + ); } + if ($pattern = $field->val('pattern')) { + $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`'; - return $clean; + if (!preg_match($regex, $value)) { + $field->addError( + 'pattern', + [ + 'messageCode' => $field->val('x-patternMessageCode', 'The value doesn\'t match the required pattern {pattern}.'), + 'pattern' => $regex, + ] + ); + } + } + if ($format = $field->val('format')) { + $type = $format; + switch ($format) { + case 'date': + $result = $this->validateDatetime($result, $field); + if ($result instanceof \DateTimeInterface) { + $result = $result->format("Y-m-d\T00:00:00P"); + } + break; + case 'email': + $result = filter_var($result, FILTER_VALIDATE_EMAIL); + break; + case 'ipv4': + $type = 'IPv4 address'; + $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + break; + case 'ipv6': + $type = 'IPv6 address'; + $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + break; + case 'ip': + $type = 'IP address'; + $result = filter_var($result, FILTER_VALIDATE_IP); + break; + case 'uri': + $type = 'URL'; + $result = filter_var($result, FILTER_VALIDATE_URL); + break; + default: + trigger_error("Unrecognized format '$format'.", E_USER_NOTICE); + } + if ($result === false) { + $field->addError('format', [ + 'format' => $format, + 'formatCode' => $type, + 'value' => $value, + 'messageCode' => '{value} is not a valid {formatCode}.' + ]); + } + } + + if ($field->isValid()) { + return $result; + } else { + return Invalid::value(); + } } /** - * Validate data against the schema and return the result. + * Validate a date time. * - * @param mixed $data The data to validate. - * @param bool $sparse Whether or not to do a sparse validation. - * @return bool Returns true if the data is valid. False otherwise. + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid. */ - public function isValid($data, $sparse = false) { - try { - $this->validate($data, $sparse); - return true; - } catch (ValidationException $ex) { - return false; + protected function validateDatetime($value, ValidationField $field) { + if ($value instanceof \DateTimeInterface) { + // do nothing, we're good + } elseif (is_string($value) && $value !== '' && !is_numeric($value)) { + try { + $dt = new \DateTimeImmutable($value); + if ($dt) { + $value = $dt; + } else { + $value = null; + } + } catch (\Throwable $ex) { + $value = Invalid::value(); + } + } elseif (is_int($value) && $value > 0) { + try { + $value = new \DateTimeImmutable('@'.(string)round($value)); + } catch (\Throwable $ex) { + $value = Invalid::value(); + } + } else { + $value = Invalid::value(); + } + + if (Invalid::isInvalid($value)) { + $field->addTypeError($value, 'date/time'); } + return $value; } /** - * Validate a field. + * Recursively resolve allOf inheritance tree and return a merged resource specification * - * @param mixed $value The value to validate. - * @param ValidationField $field A validation object to add errors to. - * @param bool $sparse Whether or not this is a sparse validation. - * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value - * is completely invalid. + * @param ValidationField $field The validation results to add. + * @return array Returns an array of merged specs. + * @throws ParseException Throws an exception if an invalid allof member is provided + * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found. */ - protected function validateField($value, ValidationField $field, $sparse = false) { - $result = $value = $this->filterField($value, $field); + private function resolveAllOfTree(ValidationField $field) { + $result = []; - if ($field->getField() instanceof Schema) { - try { - $result = $field->getField()->validate($value, $sparse); - } catch (ValidationException $ex) { - // The validation failed, so merge the validations together. - $field->getValidation()->merge($ex->getValidation(), $field->getName()); + foreach($field->getAllOf() as $allof) { + if (!is_array($allof) || empty($allof)) { + throw new ParseException("Invalid allof member in {$field->getSchemaPath()}, array expected", 500); } - } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && $field->hasType('null')) { - $result = null; - } else { - // Validate the field's type. - $type = $field->getType(); - if (is_array($type)) { - $result = $this->validateMultipleTypes($value, $type, $field, $sparse); + + list ($items, $schemaPath) = $this->lookupSchema($allof, $field->getSchemaPath()); + + $allOfValidation = new ValidationField( + $field->getValidation(), + $items, + '', + $schemaPath, + $field->getOptions() + ); + + if($allOfValidation->hasAllOf()) { + $result = array_replace_recursive($result, $this->resolveAllOfTree($allOfValidation)); } else { - $result = $this->validateSingleType($value, $type, $field, $sparse); - } - if (Invalid::isValid($result)) { - $result = $this->validateEnum($result, $field); + $result = array_replace_recursive($result, $items); } } - // Validate a custom field validator. - if (Invalid::isValid($result)) { - $this->callValidators($result, $field); - } + return $result; + } + + /** + * Validate allof tree + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return array|Invalid Returns an array or invalid if validation fails. + * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found. + */ + private function validateAllOf($value, ValidationField $field) { + $allOfValidation = new ValidationField( + $field->getValidation(), + $this->resolveAllOfTree($field), + '', + $field->getSchemaPath(), + $field->getOptions() + ); - return $result; + return $this->validateField($value, $allOfValidation); } /** @@ -745,21 +1532,20 @@ protected function validateField($value, ValidationField $field, $sparse = false * * @param mixed $value The value to validate. * @param ValidationField $field The validation results to add. - * @param bool $sparse Whether or not this is a sparse validation. * @return array|Invalid Returns an array or invalid if validation fails. + * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found. */ - protected function validateArray($value, ValidationField $field, $sparse = false) { + protected function validateArray($value, ValidationField $field) { if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) { - $field->addTypeError('array'); + $field->addTypeError($value, 'array'); return Invalid::value(); } else { if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) { $field->addError( 'minItems', [ - 'messageCode' => '{field} must contain at least {minItems} {minItems,plural,item}.', + 'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.', 'minItems' => $minItems, - 'status' => 422 ] ); } @@ -767,28 +1553,38 @@ protected function validateArray($value, ValidationField $field, $sparse = false $field->addError( 'maxItems', [ - 'messageCode' => '{field} must contain no more than {maxItems} {maxItems,plural,item}.', + 'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.', 'maxItems' => $maxItems, - 'status' => 422 + ] + ); + } + + if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) { + $field->addError( + 'uniqueItems', + [ + 'messageCode' => 'The array must contain unique items.', ] ); } if ($field->val('items') !== null) { - $result = []; + list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items'); // Validate each of the types. $itemValidation = new ValidationField( $field->getValidation(), - $field->val('items'), + $items, '', - $sparse + $schemaPath, + $field->getOptions() ); + $result = []; $count = 0; foreach ($value as $i => $item) { - $itemValidation->setName($field->getName()."[{$i}]"); - $validItem = $this->validateField($item, $itemValidation, $sparse); + $itemValidation->setName($field->getName()."/$i"); + $validItem = $this->validateField($item, $itemValidation); if (Invalid::isValid($validItem)) { $result[] = $validItem; } @@ -805,126 +1601,72 @@ protected function validateArray($value, ValidationField $field, $sparse = false } /** - * Validate a boolean value. + * Validate an object. * * @param mixed $value The value to validate. * @param ValidationField $field The validation results to add. - * @return bool|Invalid Returns the cleaned value or invalid if validation fails. + * @return object|Invalid Returns a clean object or **null** if validation fails. + * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. */ - protected function validateBoolean($value, ValidationField $field) { - $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if ($value === null) { - $field->addTypeError('boolean'); + protected function validateObject($value, ValidationField $field) { + if (!$this->isArray($value) || isset($value[0])) { + $field->addTypeError($value, 'object'); return Invalid::value(); + } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) { + // Validate the data against the internal schema. + $value = $this->validateProperties($value, $field); + } elseif (!is_array($value)) { + $value = $this->toObjectArray($value); } - return $value; - } - - /** - * Validate a date time. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid. - */ - protected function validateDatetime($value, ValidationField $field) { - if ($value instanceof \DateTimeInterface) { - // do nothing, we're good - } elseif (is_string($value) && $value !== '' && !is_numeric($value)) { - try { - $dt = new \DateTimeImmutable($value); - if ($dt) { - $value = $dt; - } else { - $value = null; - } - } catch (\Exception $ex) { - $value = Invalid::value(); - } - } elseif (is_int($value) && $value > 0) { - $value = new \DateTimeImmutable('@'.(string)round($value)); - } else { - $value = Invalid::value(); - } - - if (Invalid::isInvalid($value)) { - $field->addTypeError('datetime'); - } - return $value; - } - - /** - * Validate a float. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return float|Invalid Returns a number or **null** if validation fails. - */ - protected function validateNumber($value, ValidationField $field) { - $result = filter_var($value, FILTER_VALIDATE_FLOAT); - if ($result === false) { - $field->addTypeError('number'); - return Invalid::value(); + if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) { + $field->addError( + 'maxProperties', + [ + 'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.', + 'maxItems' => $maxProperties, + ] + ); } - $result = $this->validateNumberProperties($result, $field); - - return $result; - } - /** - * Validate and integer. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return int|Invalid Returns the cleaned value or **null** if validation fails. - */ - protected function validateInteger($value, ValidationField $field) { - $result = filter_var($value, FILTER_VALIDATE_INT); - - if ($result === false) { - $field->addTypeError('integer'); - return Invalid::value(); + if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) { + $field->addError( + 'minProperties', + [ + 'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.', + 'minItems' => $minProperties, + ] + ); } - $result = $this->validateNumberProperties($result, $field); - - return $result; + return $value; } /** - * Validate an object. + * Check whether or not a value is an array or accessible like an array. * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @param bool $sparse Whether or not this is a sparse validation. - * @return object|Invalid Returns a clean object or **null** if validation fails. + * @param mixed $value The value to check. + * @return bool Returns **true** if the value can be used like an array or **false** otherwise. */ - protected function validateObject($value, ValidationField $field, $sparse = false) { - if (!$this->isArray($value) || isset($value[0])) { - $field->addTypeError('object'); - return Invalid::value(); - } elseif (is_array($field->val('properties'))) { - // Validate the data against the internal schema. - $value = $this->validateProperties($value, $field, $sparse); - } elseif (!is_array($value)) { - $value = $this->toObjectArray($value); - } - return $value; + private function isArray($value) { + return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable); } /** * Validate data against the schema and return the result. * - * @param array|\ArrayAccess $data The data to validate. + * @param array|\Traversable|\ArrayAccess $data The data to validate. * @param ValidationField $field This argument will be filled with the validation result. - * @param bool $sparse Whether or not this is a sparse validation. - * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types. + * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types. * or invalid if there are no valid properties. + * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found. */ - protected function validateProperties($data, ValidationField $field, $sparse = false) { + protected function validateProperties($data, ValidationField $field) { $properties = $field->val('properties', []); + $additionalProperties = $field->val('additionalProperties'); $required = array_flip($field->val('required', [])); + $isRequest = $field->isRequest(); + $isResponse = $field->isResponse(); if (is_array($data)) { $keys = array_keys($data); @@ -934,43 +1676,55 @@ protected function validateProperties($data, ValidationField $field, $sparse = f $class = get_class($data); $clean = new $class; - if ($clean instanceof \ArrayObject) { + if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) { $clean->setFlags($data->getFlags()); $clean->setIteratorClass($data->getIteratorClass()); } } $keys = array_combine(array_map('strtolower', $keys), $keys); - $propertyField = new ValidationField($field->getValidation(), [], null, $sparse); + $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions()); // Loop through the schema fields and validate each one. foreach ($properties as $propertyName => $property) { + list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName)); + $propertyField ->setField($property) - ->setName(ltrim($field->getName().".$propertyName", '.')); + ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/')) + ->setSchemaPath($schemaPath); $lName = strtolower($propertyName); $isRequired = isset($required[$propertyName]); - // First check for required fields. + // Check to strip this field if it is readOnly or writeOnly. + if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) { + unset($keys[$lName]); + continue; + } + + // Check for required fields. if (!array_key_exists($lName, $keys)) { - if ($sparse) { + if ($field->isSparse()) { // Sparse validation can leave required fields out. } elseif ($propertyField->hasVal('default')) { $clean[$propertyName] = $propertyField->val('default'); } elseif ($isRequired) { - $propertyField->addError('missingField', ['messageCode' => '{field} is required.']); + $propertyField->addError( + 'required', + ['messageCode' => '{property} is required.', 'property' => $propertyName] + ); } } else { $value = $data[$keys[$lName]]; - if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->hasType('null')) { + if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) { if ($propertyField->getType() !== 'string' || $value === null) { continue; } } - $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse); + $clean[$propertyName] = $this->validateField($value, $propertyField); } unset($keys[$lName]); @@ -978,16 +1732,36 @@ protected function validateProperties($data, ValidationField $field, $sparse = f // Look for extraneous properties. if (!empty($keys)) { - if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) { - $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys)); - trigger_error($msg, E_USER_NOTICE); - } + if ($additionalProperties) { + list($additionalProperties, $schemaPath) = $this->lookupSchema( + $additionalProperties, + $field->getSchemaPath().'/additionalProperties' + ); + + $propertyField = new ValidationField( + $field->getValidation(), + $additionalProperties, + '', + $schemaPath, + $field->getOptions() + ); - if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) { - $field->addError('invalid', [ - 'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.', + foreach ($keys as $key) { + $propertyField + ->setName(ltrim($field->getName()."/$key", '/')); + + $valid = $this->validateField($data[$key], $propertyField); + if (Invalid::isValid($valid)) { + $clean[$key] = $valid; + } + } + } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) { + $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys)); + trigger_error($msg, E_USER_NOTICE); + } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) { + $field->addError('unexpectedProperties', [ + 'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.', 'extra' => array_values($keys), - 'status' => 422 ]); } } @@ -996,121 +1770,43 @@ protected function validateProperties($data, ValidationField $field, $sparse = f } /** - * Validate a string. + * Escape a JSON reference field. * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return string|Invalid Returns the valid string or **null** if validation fails. + * @param string $field The reference field to escape. + * @return string Returns an escaped reference. */ - protected function validateString($value, ValidationField $field) { - if (is_string($value) || is_numeric($value)) { - $value = $result = (string)$value; - } else { - $field->addTypeError('string'); - return Invalid::value(); - } - - $errorCount = $field->getErrorCount(); - $strFn = $this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE) ? "mb_strlen" : "strlen"; - $strLen = $strFn($value); - if (($minLength = $field->val('minLength', 0)) > 0 && $strLen < $minLength) { - if (!empty($field->getName()) && $minLength === 1) { - $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]); - } else { - $field->addError( - 'minLength', - [ - 'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.', - 'minLength' => $minLength, - 'status' => 422 - ] - ); - } - } - if (($maxLength = $field->val('maxLength', 0)) > 0 && $strLen > $maxLength) { - $field->addError( - 'maxLength', - [ - 'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.', - 'maxLength' => $maxLength, - 'overflow' => $strLen - $maxLength, - 'status' => 422 - ] - ); - } - if ($pattern = $field->val('pattern')) { - $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`'; - - if (!preg_match($regex, $value)) { - $field->addError( - 'invalid', - [ - 'messageCode' => '{field} is in the incorrect format.', - 'status' => 422 - ] - ); - } - } - if ($format = $field->val('format')) { - $type = $format; - switch ($format) { - case 'date-time': - $result = $this->validateDatetime($result, $field); - if ($result instanceof \DateTimeInterface) { - $result = $result->format(\DateTime::RFC3339); - } - break; - case 'email': - $result = filter_var($result, FILTER_VALIDATE_EMAIL); - break; - case 'ipv4': - $type = 'IPv4 address'; - $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); - break; - case 'ipv6': - $type = 'IPv6 address'; - $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); - break; - case 'ip': - $type = 'IP address'; - $result = filter_var($result, FILTER_VALIDATE_IP); - break; - case 'uri': - $type = 'URI'; - $result = filter_var($result, FILTER_VALIDATE_URL); - break; - default: - trigger_error("Unrecognized format '$format'.", E_USER_NOTICE); - } - if ($result === false) { - $field->addTypeError($type); - } - } + public static function escapeRef(string $field): string { + return str_replace(['~', '/'], ['~0', '~1'], $field); + } - if ($field->isValid()) { - return $result; - } else { - return Invalid::value(); - } + /** + * Whether or not the schema has a flag (or combination of flags). + * + * @param int $flag One or more of the **Schema::VALIDATE_*** constants. + * @return bool Returns **true** if all of the flags are set or **false** otherwise. + */ + public function hasFlag(int $flag): bool { + return ($this->flags & $flag) === $flag; } /** - * Validate a unix timestamp. + * Cast a value to an array. * - * @param mixed $value The value to validate. - * @param ValidationField $field The field being validated. - * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate. + * @param \Traversable $value The value to convert. + * @return array Returns an array. */ - protected function validateTimestamp($value, ValidationField $field) { - if (is_numeric($value) && $value > 0) { - $result = (int)$value; - } elseif (is_string($value) && $ts = strtotime($value)) { - $result = $ts; - } else { - $field->addTypeError('timestamp'); - $result = Invalid::value(); + private function toObjectArray(\Traversable $value) { + $class = get_class($value); + if ($value instanceof \ArrayObject) { + return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass()); + } elseif ($value instanceof \ArrayAccess) { + $r = new $class; + foreach ($value as $k => $v) { + $r[$k] = $v; + } + return $r; } - return $result; + return iterator_to_array($value); } /** @@ -1124,7 +1820,7 @@ protected function validateNull($value, ValidationField $field) { if ($value === null) { return null; } - $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]); + $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']); return Invalid::value(); } @@ -1143,11 +1839,10 @@ protected function validateEnum($value, ValidationField $field) { if (!in_array($value, $enum, true)) { $field->addError( - 'invalid', + 'enum', [ - 'messageCode' => '{field} must be one of: {enum}.', + 'messageCode' => 'The value must be one of: {enum}.', 'enum' => $enum, - 'status' => 422 ] ); return Invalid::value(); @@ -1155,35 +1850,17 @@ protected function validateEnum($value, ValidationField $field) { return $value; } - /** - * Call all of the filters attached to a field. - * - * @param mixed $value The field value being filtered. - * @param ValidationField $field The validation object. - * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned. - */ - protected function callFilters($value, ValidationField $field) { - // Strip array references in the name except for the last one. - $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName()); - if (!empty($this->filters[$key])) { - foreach ($this->filters[$key] as $filter) { - $value = call_user_func($filter, $value, $field); - } - } - return $value; - } - /** * Call all of the validators attached to a field. * * @param mixed $value The field value being validated. * @param ValidationField $field The validation object to add errors. */ - protected function callValidators($value, ValidationField $field) { + private function callValidators($value, ValidationField $field) { $valid = true; // Strip array references in the name except for the last one. - $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName()); + $key = $field->getSchemaPath(); if (!empty($this->validators[$key])) { foreach ($this->validators[$key] as $validator) { $r = call_user_func($validator, $value, $field); @@ -1196,7 +1873,7 @@ protected function callValidators($value, ValidationField $field) { // Add an error on the field if the validator hasn't done so. if (!$valid && $field->isValid()) { - $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]); + $field->addError('invalid', ['messageCode' => 'The value is invalid.']); } } @@ -1210,9 +1887,28 @@ protected function callValidators($value, ValidationField $field) { * @link http://json-schema.org/ */ public function jsonSerialize() { - $fix = function ($schema) use (&$fix) { + $seen = [$this]; + return $this->jsonSerializeInternal($seen); + } + + /** + * Return the JSON data for serialization with massaging for Open API. + * + * - Swap data/time & timestamp types for Open API types. + * - Turn recursive schema pointers into references. + * + * @param Schema[] $seen Schemas that have been seen during traversal. + * @return array Returns an array of data that `json_encode()` will recognize. + */ + private function jsonSerializeInternal(array $seen): array { + $fix = function ($schema) use (&$fix, $seen) { if ($schema instanceof Schema) { - return $schema->jsonSerialize(); + if (in_array($schema, $seen, true)) { + return ['$ref' => '#/components/schemas/'.($schema->getID() ?: '$no-id')]; + } else { + $seen[] = $schema; + return $schema->jsonSerializeInternal($seen); + } } if (!empty($schema['type'])) { @@ -1251,30 +1947,14 @@ public function jsonSerialize() { return $result; } - /** - * Look up a type based on its alias. - * - * @param string $alias The type alias or type name to lookup. - * @return mixed - */ - protected function getType($alias) { - if (isset(self::$types[$alias])) { - return $alias; - } - foreach (self::$types as $type => $aliases) { - if (in_array($alias, $aliases, true)) { - return $type; - } - } - return null; - } - /** * Get the class that's used to contain validation information. * * @return Validation|string Returns the validation class. + * @deprecated */ public function getValidationClass() { + trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED); return $this->validationClass; } @@ -1283,62 +1963,27 @@ public function getValidationClass() { * * @param Validation|string $class Either the name of a class or a class that will be cloned. * @return $this + * @deprecated */ public function setValidationClass($class) { + trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED); + if (!is_a($class, Validation::class, true)) { throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500); } + $this->setValidationFactory(function () use ($class) { + if ($class instanceof Validation) { + $result = clone $class; + } else { + $result = new $class; + } + return $result; + }); $this->validationClass = $class; return $this; } - /** - * Create a new validation instance. - * - * @return Validation Returns a validation object. - */ - protected function createValidation() { - $class = $this->getValidationClass(); - - if ($class instanceof Validation) { - $result = clone $class; - } else { - $result = new $class; - } - return $result; - } - - /** - * Check whether or not a value is an array or accessible like an array. - * - * @param mixed $value The value to check. - * @return bool Returns **true** if the value can be used like an array or **false** otherwise. - */ - private function isArray($value) { - return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable); - } - - /** - * Cast a value to an array. - * - * @param \Traversable $value The value to convert. - * @return array Returns an array. - */ - private function toObjectArray(\Traversable $value) { - $class = get_class($value); - if ($value instanceof \ArrayObject) { - return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass()); - } elseif ($value instanceof \ArrayAccess) { - $r = new $class; - foreach ($value as $k => $v) { - $r[$k] = $v; - } - return $r; - } - return iterator_to_array($value); - } - /** * Return a sparse version of this schema. * @@ -1388,40 +2033,24 @@ private function withSparseInternal($schema, \SplObjectStorage $schemas) { } /** - * Filter a field's value using built in and custom filters. + * Get the ID for the schema. * - * @param mixed $value The original value of the field. - * @param ValidationField $field The field information for the field. - * @return mixed Returns the filtered field or the original field value if there are no filters. + * @return string */ - private function filterField($value, ValidationField $field) { - // Check for limited support for Open API style. - if (!empty($field->val('style')) && is_string($value)) { - $doFilter = true; - if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) { - $doFilter = false; - } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) { - $doFilter = false; - } - - if ($doFilter) { - switch ($field->val('style')) { - case 'form': - $value = explode(',', $value); - break; - case 'spaceDelimited': - $value = explode(' ', $value); - break; - case 'pipeDelimited': - $value = explode('|', $value); - break; - } - } - } + public function getID(): string { + return $this->schema['id'] ?? ''; + } - $value = $this->callFilters($value, $field); + /** + * Set the ID for the schema. + * + * @param string $id The new ID. + * @return $this + */ + public function setID(string $id) { + $this->schema['id'] = $id; - return $value; + return $this; } /** @@ -1468,172 +2097,108 @@ public function offsetUnset($offset) { } /** - * Validate a field against a single type. - * - * @param mixed $value The value to validate. - * @param string $type The type to validate against. - * @param ValidationField $field Contains field and validation information. - * @param bool $sparse Whether or not this should be a sparse validation. - * @return mixed Returns the valid value or `Invalid`. - */ - protected function validateSingleType($value, $type, ValidationField $field, $sparse) { - switch ($type) { - case 'boolean': - $result = $this->validateBoolean($value, $field); - break; - case 'integer': - $result = $this->validateInteger($value, $field); - break; - case 'number': - $result = $this->validateNumber($value, $field); - break; - case 'string': - $result = $this->validateString($value, $field); - break; - case 'timestamp': - $result = $this->validateTimestamp($value, $field); - break; - case 'datetime': - $result = $this->validateDatetime($value, $field); - break; - case 'array': - $result = $this->validateArray($value, $field, $sparse); - break; - case 'object': - $result = $this->validateObject($value, $field, $sparse); - break; - case 'null': - $result = $this->validateNull($value, $field); - break; - case null: - // No type was specified so we are valid. - $result = $value; - break; - default: - throw new \InvalidArgumentException("Unrecognized type $type.", 500); - } - return $result; - } - - /** - * Validate a field against multiple basic types. + * Resolve the schema attached to a discriminator. * - * The first validation that passes will be returned. If no type can be validated against then validation will fail. - * - * @param mixed $value The value to validate. - * @param string[] $types The types to validate against. - * @param ValidationField $field Contains field and validation information. - * @param bool $sparse Whether or not this should be a sparse validation. - * @return mixed Returns the valid value or `Invalid`. - */ - private function validateMultipleTypes($value, array $types, ValidationField $field, $sparse) { - // First check for an exact type match. - switch (gettype($value)) { - case 'boolean': - if (in_array('boolean', $types)) { - $singleType = 'boolean'; - } - break; - case 'integer': - if (in_array('integer', $types)) { - $singleType = 'integer'; - } elseif (in_array('number', $types)) { - $singleType = 'number'; - } - break; - case 'double': - if (in_array('number', $types)) { - $singleType = 'number'; - } elseif (in_array('integer', $types)) { - $singleType = 'integer'; - } - break; - case 'string': - if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) { - $singleType = 'datetime'; - } elseif (in_array('string', $types)) { - $singleType = 'string'; - } - break; - case 'array': - if (in_array('array', $types) && in_array('object', $types)) { - $singleType = isset($value[0]) || empty($value) ? 'array' : 'object'; - } elseif (in_array('object', $types)) { - $singleType = 'object'; - } elseif (in_array('array', $types)) { - $singleType = 'array'; - } - break; - case 'NULL': - if (in_array('null', $types)) { - $singleType = $this->validateSingleType($value, 'null', $field, $sparse); - } - break; - } - if (!empty($singleType)) { - return $this->validateSingleType($value, $singleType, $field, $sparse); + * @param mixed $value The value to search for the discriminator. + * @param ValidationField $field The current node's schema information. + * @return ValidationField|null Returns the resolved schema or **null** if it can't be resolved. + * @throws ParseException Throws an exception if the discriminator isn't a string. + */ + private function resolveDiscriminator($value, ValidationField $field, array $visited = []) { + $propertyName = $field->val('discriminator')['propertyName'] ?? ''; + if (empty($propertyName) || !is_string($propertyName)) { + throw new ParseException("Invalid propertyName for discriminator at {$field->getSchemaPath()}", 500); } - // Clone the validation field to collect errors. - $typeValidation = new ValidationField(new Validation(), $field->getField(), '', $sparse); + $propertyFieldName = ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'); - // Try and validate against each type. - foreach ($types as $type) { - $result = $this->validateSingleType($value, $type, $typeValidation, $sparse); - if (Invalid::isValid($result)) { - return $result; - } + // Do some basic validation checking to see if we can even look at the property. + if (!$this->isArray($value)) { + $field->addTypeError($value, 'object'); + return null; + } elseif (empty($value[$propertyName])) { + $field->getValidation()->addError( + $propertyFieldName, + 'required', + ['messageCode' => '{property} is required.', 'property' => $propertyName] + ); + return null; } - // Since we got here the value is invalid. - $field->merge($typeValidation->getValidation()); - return Invalid::value(); - } - - /** - * Validate specific numeric validation properties. - * - * @param int|float $value The value to test. - * @param ValidationField $field Field information. - * @return int|float|Invalid Returns the number of invalid. - */ - private function validateNumberProperties($value, ValidationField $field) { - $count = $field->getErrorCount(); + $propertyValue = $value[$propertyName]; + if (!is_string($propertyValue)) { + $field->getValidation()->addError( + $propertyFieldName, + 'type', + [ + 'type' => 'string', + 'value' => is_scalar($value) ? $value : null, + 'messageCode' => is_scalar($value) ? "{value} is not a valid string." : "The value is not a valid string." + ] + ); + return null; + } - if ($multipleOf = $field->val('multipleOf')) { - $divided = $value / $multipleOf; + $mapping = $field->val('discriminator')['mapping'] ?? ''; + if (isset($mapping[$propertyValue])) { + $ref = $mapping[$propertyValue]; - if ($divided != round($divided)) { - $field->addError('multipleOf', ['messageCode' => '{field} is not a multiple of {multipleOf}.', 'status' => 422, 'multipleOf' => $multipleOf]); + if (strpos($ref, '#') === false) { + $ref = '#/components/schemas/'.self::escapeRef($ref); } + } else { + // Don't let a property value provide its own ref as that may pose a security concern.. + $ref = '#/components/schemas/'.self::escapeRef($propertyValue); } - if ($maximum = $field->val('maximum')) { - $exclusive = $field->val('exclusiveMaximum'); - - if ($value > $maximum || ($exclusive && $value == $maximum)) { - if ($exclusive) { - $field->addError('maximum', ['messageCode' => '{field} is greater than or equal to {maximum}.', 'status' => 422, 'maximum' => $maximum]); - } else { - $field->addError('maximum', ['messageCode' => '{field} is greater than {maximum}.', 'status' => 422, 'maximum' => $maximum]); - } - - } + // Validate the reference against the oneOf constraint. + $oneOf = $field->val('oneOf', []); + if (!empty($oneOf) && !in_array(['$ref' => $ref], $oneOf)) { + $field->getValidation()->addError( + $propertyFieldName, + 'oneOf', + [ + 'type' => 'string', + 'value' => is_scalar($propertyValue) ? $propertyValue : null, + 'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option." + ] + ); + return null; } - if ($minimum = $field->val('minimum')) { - $exclusive = $field->val('exclusiveMinimum'); + try { + // Lookup the schema. + $visited[$field->getSchemaPath()] = true; - if ($value < $minimum || ($exclusive && $value == $minimum)) { - if ($exclusive) { - $field->addError('minimum', ['messageCode' => '{field} is greater than or equal to {minimum}.', 'status' => 422, 'minimum' => $minimum]); - } else { - $field->addError('minimum', ['messageCode' => '{field} is greater than {minimum}.', 'status' => 422, 'minimum' => $minimum]); - } + list($schema, $schemaPath) = $this->lookupSchema(['$ref' => $ref], $field->getSchemaPath()); + if (isset($visited[$schemaPath])) { + throw new RefNotFoundException('Cyclical ref.', 508); + } + $result = new ValidationField( + $field->getValidation(), + $schema, + $field->getName(), + $schemaPath, + $field->getOptions() + ); + if (!empty($schema['discriminator'])) { + return $this->resolveDiscriminator($value, $result, $visited); + } else { + return $result; } + } catch (RefNotFoundException $ex) { + // Since this is a ref provided by the value it is technically a validation error. + $field->getValidation()->addError( + $propertyFieldName, + 'propertyName', + [ + 'type' => 'string', + 'value' => is_scalar($propertyValue) ? $propertyValue : null, + 'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option." + ] + ); + return null; } - - return $field->getErrorCount() === $count ? $value : Invalid::value(); } } diff --git a/tests/PropertyTest.php b/tests/PropertyTest.php index 8a77df5..7f61d92 100644 --- a/tests/PropertyTest.php +++ b/tests/PropertyTest.php @@ -26,6 +26,7 @@ public function testPropertyAccess() { $this->assertSame('foo', $schema->jsonSerialize()['description']); $this->assertSame('foo', $schema['description']); + $schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false); $this->assertSame(0, $schema->getFlags()); $behaviors = [ Schema::VALIDATE_EXTRA_PROPERTY_NOTICE, diff --git a/tests/StringValidationTest.php b/tests/StringValidationTest.php index 1c23ad1..f5336ad 100644 --- a/tests/StringValidationTest.php +++ b/tests/StringValidationTest.php @@ -59,7 +59,7 @@ public function provideMinLengthTests() { 'abcd' => ['abcd', ''], 'empty 1' => ['', 'missingField', 1], 'empty 0' => ['', '', 0], - 'unicode as bytes success' => ['😱', '', 4], + 'unicode as bytes success' => ['😱', 'minLength', 4], 'unicode as unicode fail' => ['😱', 'minLength', 2, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], 'unicode as unicode success' => ['😱', '', 1, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], ]; @@ -73,19 +73,14 @@ public function provideMinLengthTests() { * @param string $str The string to test. * @param string $code The expected error code, if any. * @param int $maxLength The max length to test. - * @param int $flags Flags to set on the schema. * * @dataProvider provideMaxLengthTests */ - public function testMaxLength($str, string $code = '', int $maxLength = 3, int $flags = null) { + public function testMaxLength($str, string $code = '', int $maxLength = 3) { $schema = Schema::parse(['str:s?' => [ 'maxLength' => $maxLength, ]]); - if ($flags !== null) { - $schema->setFlags($flags); - } - try { $schema->validate(['str' => $str]); @@ -111,14 +106,76 @@ public function provideMaxLengthTests() { 'ab' => ['ab'], 'abc' => ['abc'], 'abcd' => ['abcd', 'maxLength'], - 'long multibyte with unicode length' => ['😱', '', 2, Schema::VALIDATE_STRING_LENGTH_AS_UNICODE], - 'long multibyte with byte length' => ['😱', 'maxLength', 2], - 'exact amount multibyte with byte length' => ['😱', '', 4], ]; return $r; } + /** + * Test byte length validation. + * + * @param array $value + * @param string|array|null $exceptionMessages Null, an expected exception message, or multiple expected exception messages. + * @param bool $forceByteLength Set this to true to force all maxLengths to be byte length. + * + * @dataProvider provideByteLengths + */ + public function testByteLengthValidation(array $value, $exceptionMessages = null, bool $forceByteLength = false) { + $schema = Schema::parse([ + 'justLength:s?' => [ + 'maxLength' => 4, + ], + 'justByteLength:s?' => [ + 'maxByteLength' => 8, + ], + 'mixedLengths:s?' => [ + 'maxLength' => 4, + 'maxByteLength' => 6 + ], + ]); + if ($forceByteLength) { + $schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false); + } + + try { + $schema->validate($value); + // We were expecting success. + $this->assertTrue(true); + } catch (ValidationException $e) { + if ($exceptionMessages !== null) { + $actual = $e->getMessage(); + $exceptionMessages = is_array($exceptionMessages) ? $exceptionMessages : [$exceptionMessages]; + foreach ($exceptionMessages as $expected) { + $this->assertContains($expected, $actual); + } + } else { + throw $e; + } + } + } + + /** + * @return array + */ + public function provideByteLengths() { + return [ + 'maxLength - short' => [['justLength' => '😱']], + 'maxLength - equal' => [['justLength' => '😱😱😱😱']], + 'maxLength - long' => [['justLength' => '😱😱😱😱😱'], '1 character too long'], + 'byteLength - short' => [['justByteLength' => '😱']], + 'byteLength - equal' => [['justByteLength' => '😱😱']], + 'byteLength - long' => [['justByteLength' => '😱😱a'], '1 byte too long'], + 'mixedLengths - short' => [['mixedLengths' => '😱']], + 'mixedLengths - equal' => [['mixedLengths' => '😱aa']], + 'mixedLengths - long bytes' => [['mixedLengths' => '😱😱'], '2 bytes too long'], + 'mixedLengths - long chars' => [['mixedLengths' => 'aaaaa'], '1 character too long'], + 'mixedLengths - long chars - long bytes' => [['mixedLengths' => '😱😱😱😱😱'], ["1 character too long", "14 bytes too long."]], + 'byteLength flag - short' => [['justLength' => '😱'], null, true], + 'byteLength flag - long' => [['justLength' => '😱😱😱😱'], '12 bytes too long', true], + 'byteLength property is preferred over byte length flag' => [['mixedLengths' => '😱😱'], '2 bytes too long', true] + ]; + } + /** * Test string pattern constraints. * From 3d2ac54bc9e67f41bf7c115b6032c5cd73535684 Mon Sep 17 00:00:00 2001 From: Adam Charron Date: Wed, 15 Dec 2021 12:16:04 -0500 Subject: [PATCH 2/3] Fix conflicts --- README.md | 339 +---- src/Schema.php | 2416 +++++++++++++------------------- tests/StringValidationTest.php | 6 +- 3 files changed, 1011 insertions(+), 1750 deletions(-) diff --git a/README.md b/README.md index 8a1661f..67c2bbc 100644 --- a/README.md +++ b/README.md @@ -6,35 +6,35 @@ ![MIT License](https://img.shields.io/packagist/l/vanilla/garden-schema.svg?style=flat) [![CLA](https://cla-assistant.io/readme/badge/vanilla/garden-schema)](https://cla-assistant.io/vanilla/garden-schema) -The Garden Schema is a simple data validation and cleaning library based on [OpenAPI 3.0 Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject). +The Garden Schema is a simple data validation and cleaning library based on [JSON Schema](http://json-schema.org/). ## Features -- Define the data structures of PHP arrays of any depth, and validate them. +- Define the data structures of PHP arrays of any depth, and validate them. -- Validated data is cleaned and coerced into appropriate types. +- Validated data is cleaned and coerced into appropriate types. -- The schema defines a whitelist of allowed data and strips out all extraneous data. +- The schema defines a whitelist of allowed data and strips out all extraneous data. -- The **Schema** class understands a subset of data in [OpenAPI Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject) format. We will add more support for the built-in JSON schema validation as time goes on. +- The **Schema** class understands data in [JSON Schema](http://json-schema.org/) format. We will add more support for the built-in JSON schema validation as time goes on. -- Developers can use a shorter schema format in order to define schemas in code rapidly. We built this class to be as easy to use as possible. Avoid developer groans as they lock down their data. +- Developers can use a shorter schema format in order to define schemas in code rapidly. We built this class to be as easy to use as possible. Avoid developer groans as they lock down their data. -- Add custom validator callbacks to support practically any validation scenario. +- Add custom validator callbacks to support practically any validation scenario. -- Override the validation class in order to customize the way errors are displayed for your own application. +- Override the validation class in order to customize the way errors are displayed for your own application. ## Uses Garden Schema is meant to be a generic wrapper for data validation. It should be valuable when you want to bullet-proof your code against user-submitted data. Here are some example uses: -- Check the data being submitted to your API endpoints. Define the schema at the beginning of your endpoint and validate the data before doing anything else. In this way you can be sure that you are using clean data and avoid a bunch of spaghetti checks later in your code. This was the original reason why we developed the Garden Schema. +- Check the data being submitted to your API endpoints. Define the schema at the beginning of your endpoint and validate the data before doing anything else. In this way you can be sure that you are using clean data and avoid a bunch of spaghetti checks later in your code. This was the original reason why we developed the Garden Schema. -- Clean user input. The Schema object will cast data to appropriate types and gracefully handle common use-cases (ex. converting the string "true" to true for booleans). This allows you to use more "===" checks in your code which helps avoid bugs in the longer term. +- Clean user input. The Schema object will cast data to appropriate types and gracefully handle common use-cases (ex. converting the string "true" to true for booleans). This allows you to use more "===" checks in your code which helps avoid bugs in the longer term. -- Validate data before passing it to the database in order to present human-readable errors rather than cryptic database generated errors. +- Validate data before passing it to the database in order to present human-readable errors rather than cryptic database generated errors. -- Clean output before returning it. A lot of database drivers return data as strings even though it's defined as different types. The Schema will clean the data appropriately which is especially important for consumption by the non-PHP world. +- Clean output before returning it. A lot of database drivers return data as strings even though it's defined as different types. The Schema will clean the data appropriately which is especially important for consumption by the non-PHP world. ## Basic Usage @@ -55,7 +55,7 @@ In the above example a **Schema** object is created with the schema definition p ## Defining Schemas -The **Schema** class is instantiated with an array defining the schema. The array can be in [OpenAPI 3.0 Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject) format or it can be in custom short format. It is recommended you define your schemas in the OpenAPI format, but the short format is good for those wanting to write quick prototypes. The short format will be described in this section. +The **Schema** class is instantiated with an array defining the schema. The array can be in [JSON Schema](http://json-schema.org/) format or it can be in custom short format which is much quicker to write. The short format will be described in this section. By default the schema is an array where each element of the array defines an object property. By "object" we mean javascript object or PHP array with string keys. There are several ways a property can be defined: @@ -78,7 +78,7 @@ By default the schema is an array where each element of the array defines an obj ... ] ] - ``` +``` You can quickly define an object schema by giving just as much information as you need. You can create a schema that is nested as deeply as you want in order to validate very complex data. This short schema is converted into a JSON schema compatible array internally and you can see this array with the **jsonSerialize()** method. @@ -86,16 +86,18 @@ We provide first-class support for descriptions because we believe in writing re ### Types and Short Types -The **Schema** class supports the following types. Each type has one or more aliases. You can use an alias for brevity when defining a schema in code and it gets converted to the proper type internally, including when used in errors. +The **Schema** class supports the following types. Each type has a short-form and a long-form. Usually you use the short-form when defining a schema in code and it gets converted to the long-form internally, including when used in errors. -Type | Aliases | Notes | ----- | ------- | ----- | -boolean | b, bool | -string | s, str, dt | The "dt" alias adds a format of "date-time" and validates to `DateTimeInterface` instances | -integer | i, int, ts | The "ts" alias adds a format of "timestamp" and will convert date strings into integer timestamps on validation. | -number | f, float | -array | a | -object | o | +| Type | Short-form | +| --------- | ---------- | +| boolean | b, bool | +| string | s, str | +| integer | i, int | +| number | f, float | +| timestamp | ts | +| datetime | dt | +| array | a | +| object | o | ### Arrays and Objects @@ -138,25 +140,29 @@ This schema would apply to something like the following data: ] ``` -### Optional Properties and Nullable Properties +### Optional Properties and Allow Null -When defining an object schema you can use a "?" to say that the property is optional. This means that the property can be completely omitted during validation. This is not the same a providing a **null** value for the property which is considered invalid for optional properties. +When defining an object schema you can use a "?" to say that the property is optional. This means that the property can be completely omitted during validation. This is not the same a providing a null value for the property which is considered invalid for optional properties. -If you want a property to allow null values you can specify the `nullable` attribute on the property. There are two ways to do this: +If you want a property to allow null values you can specify the **allowNull** attribute on the property. There are two ways to do this: ```php [ - // You can specify nullable as a property attribute. - 'opt1:s?' => ['nullable' => true], + // You can specify allowNull as a property attribute. + 'opt1:s?' => ['allowNull' => true], // You can specify null as an optional type in the declaration. - 'opt2:s|n?' => 'Another nullable, optional property.' + 'opt2:s|n?' => 'Another optional property.' ] ``` +### Multiple Types + +The type property of the schema can accept an array of types. An array of types means that the data must be any one of the types. + ### Default Values -You can specify a default value with the `default` attribute. If the value is omitted during validation then the default value will be used. Note that default values are not applied during sparse validation. +You can specify a default value on object properties. If the property is omitted during validation then the default value will be used. Note that default values are not applied during sparse validation. ## Validating Data @@ -199,177 +205,11 @@ When you call **validate()** and validation fails a **ValidationException** is t If you are writing an API, you can **json_encode()** the **ValidationException** and it should provide a rich set of data that will help any consumer figure out exactly what they did wrong. You can also use various properties of the **Validation** property to help render the error output appropriately. -#### The Validation JSON Format - -The `Validation` object and `ValidationException` both encode to a [specific format]('./open-api.json'). Here is an example: - -```js -ValidationError = { - "message": "string", // Main error message. - "code": "integer", // HTTP-style status code. - "errors": { // Specific field errors. - "": [ // Each key is a JSON reference field name. - { - "message": "string", // Field error message. - "error": "string", // Specific error code, usually a schema attribute. - "code": "integer" // Optional field error code. - } - ] - } -} -``` - -This format is optimized for helping present errors to user interfaces. You can loop through the specific `errors` collection and line up errors with their inputs on a user interface. For deeply nested objects, the field name is a JSON reference. - -## Schema References - -OpenAPI allows for schemas to be accessed with references using the `$ref` attribute. Using references allows you to define commonly used schemas in one place and then reference them from many locations. - -To use references you must: - -1. Define the schema you want to reference somewhere. -2. Reference the schema with a `$ref` attribute. -3. Add a schema lookup function to your main schema with `Schema::setRefLookp()` - -### Defining a Reusable Schema - -The OpenAPI specification places all reusable schemas under `/components/schemas`. If you are defining everything in a big array that is a good place to put them. - -```php -$components = [ - 'components' => [ - 'schemas' => [ - 'User' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'integer' - ], - 'username' => [ - 'type' => 'string' - ] - ] - ] - ] - ] -] -``` - -### Referencing Schemas With `$ref` - -Reference the schema's path with keys separated by `/` characters. - -```php -$userArray = [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/User' - ] -] -``` - -### Using `Schema::setRefLookup()` to Resolve References - -The `Schema` class has a `setRefLookup()` method that lets you add a callable that is use to resolve references. The callable should have the following signature: - -```php -function(string $ref): array|Schema|null { - ... -} -``` - -The function takes the string from the `$ref` attribute and returns a schema array, `Schema` object, or **null** if the schema cannot be found. Garden Schema has a default implementation of a ref lookup in the `ArrayRefLookup` class that can resolve references from a static array. This should be good enough for most uses, but you are always free to define your own. - -You can put everything together like this: - -```php -$sch = new Schema($userArray); -$sch->setRefLookup(new ArrayRefLookup($components)); - -$valid = $sch->validate(...); -``` - -The references are resolved during validation so if there are any mistakes in your references then a `RefNotFoundException` is thrown during validation, not when you set your schema or ref lookup function. - -## Schema Polymorphism - -Schemas have some support for implementing schema polymorphism by letting you validate an object against different schemas depending on its value. - -### The `discriminator` Property - -The `discriminator` of a schema lets you specify an object property that specifies what type of object it is. That property is then used to reference a specific schema for the object. The discriminator has the following format: - -```json5 -{ - "discriminator": { - "propertyName": "", // Name of the property used to reference a schema. - "mapping": { - "": "", // Reference to a schema. - "": "" // Map a value to another value. - } - } -} -``` - -You can see above that the `propertyName` specifies which property is used as the discriminator. There is also an optional `mapping` property that lets you control how schemas are mapped to values. discriminators are resolved int he following way: - -1. The property value is mapped using the mapping property. -2. If the value is a valid JSON reference then it is looked up. Only values in mappings can specify a JSON reference in this way. -3. If the value is not a valid JSON reference then it is is prepended with `#/components/schemas/` to make a JSON reference. - -Here is an example at work: - -```json5 -{ - "discriminator": { - "propertyName": "petType", - "mapping": { - "dog": "#/components/schemas/Dog", // A direct reference. - "fido": "Dog" // An alias that will be turned into a reference. - } - } -} -``` - -### The `oneOf` Property - -The `oneOf` property works in conjunction with the `discriminator` to limit the schemas that the object is allowed to validate against. If you don't specify `oneOf` then any schemas under `#/components/schemas` are fair game. - -To use the `oneOf` property you must specify `$ref` nodes like so: - -```json5 -{ - "oneOf": [ - { "$ref": "#/components/schemas/Dog" }, - { "$ref": "#/components/schemas/Cat" }, - { "$ref": "#/components/schemas/Mouse" }, - ], - "discriminator": { - "propertyType": "species" - } -} -``` - -In the above example the "species" property will be used to construct a reference to a schema. That reference must match one of the references in the `oneOf` property. - -*If you are familiar with with OpenAPI spec please note that inline schemas are not currently supported for oneOf in Garden Schema.* - - -## Validation Options - -Both **validate()** and **isValid()** can take an additional **$options** argument which modifies the behavior of the validation slightly, depending on the option. - -### The `request` Option - -You can pass an option of `['request' => true]` to specify that you are validating request data. When validating request data, properties that have been marked as `readOnly: true` will be treated as if they don't exist, even if they are marked as required. - -### The `response` Option +### Sparse Validation -You can pass an option of `['response' => true]` to specify that you are validating response data. When validating response data, properties that have been marked as `writeOnly: true` will be treated as if they don't exist, even if they are marked as required. +Both **validate()** and **isValid()** can take an additional **$sparse** parameter which does a sparse validation if set to true. -### The `sparse` Option - -You can pass an option of `['sparse' => true]` to specify a sparse validation. When you do a sparse validation, missing properties do not give errors and the sparse data is returned. Sparse validation allows you to use the same schema for inserting vs. updating records. This is common in databases or APIs with POST vs. PATCH requests. +When you do a sparse validation, missing properties do not give errors and the sparse data is returned. Sparse validation allows you to use the same schema for inserting vs. updating records. This is common in databases or APIs with POST vs. PATCH requests. ## Flags @@ -409,44 +249,6 @@ Set this flag to trigger notices whenever a validated object has properties not Set this flag to throw an exception whenever a validated object has properties not defined in the schema. -## Custom Validation with addValidator() - -You can customize validation with `Schema::addValidator()`. This method lets you attach a callback to a schema path. The callback has the following form: - -```php -function (mixed $value, ValidationField $field): bool { -} -``` - -The callback should `true` if the value is valid or `false` otherwise. You can use the provided `ValidationField` to add custom error messages. - -## Filtering Data - -You can filter data before it is validating using `Schema::addFilter()`. This method lets you filter data at a schema path. The callback has the following form: - -```php -function (mixed $value, ValidationField $field): mixed { -} -``` - -The callback should return the filtered value. Filters are called before validation occurs so you can use them to clean up date you know may need some extra processing. - -The `Schema::addFilter()` also accepts `$validate` parameter that allows your filter to validate the data and bypass default validation. If you are validating date in this way you can add custom errors to the `ValidationField` parameter and return `Invalid::value()` your validation fails. - -### Format Filters - -You can also filter all fields with a particular format using the `Schema::addFormatFilter()`. This method works similar to `Schema::addFilter()` but it applies to all fields that match the given `format`. You can even use format filters to override default format processing. - -```php -$schema = new Schema([...]); - -// By default schema returns instances of DateTimeImmutable, instead return a string. -$schema->addFormatFilter('date-time', function ($v) { - $dt = new \DateTime($v); - return $dt->format(\DateTime::RFC3339); -}, true); -``` - ## Overriding the Validation Class and Localization Since schemas generate error messages, localization may be an issue. Although the Garden Schema doesn't offer any localization capabilities itself, it is designed to be extended in order to add localization yourself. You do this by subclassing the **Validation** class and overriding its **translate()** method. Here is a basic example: @@ -470,47 +272,30 @@ $schema->setValidationClass(LocalizedValidation::class); There are a few things to note in the above example: -- When overriding **translate()** be sure to handle the case where a string starts with the '@' character. Such strings should not be translated and have the character removed. +- When overriding **translate()** be sure to handle the case where a string starts with the '@' character. Such strings should not be translated and have the character removed. -- You tell a **Schema** object to use your specific **Validation** subclass with the **setValidationClass()**. This method takes either a class name or an object instance. If you pass an object it will be cloned every time a validation object is needed. This is good when you want to use dependency injection and your class needs more sophisticated instantiation. +- You tell a **Schema** object to use your specific **Validation** subclass with the **setValidationClass()**. This method takes either a class name or an object instance. If you pass an object it will be cloned every time a validation object is needed. This is good when you want to use dependency injection and your class needs more sophisticated instantiation. ## JSON Schema Support -The **Schema** object is a wrapper for an [OpenAPI Schema](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject) array. This means that you can pass a valid JSON schema to Schema's constructor. The table below lists the JSON Schema properties that are supported. - -| Property | Applies To | Notes | -| -------- | ---------- | ----------- | -| [allOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7.1) | Schema[] | An instance validates successfully against this keyword if it validates successfully against all schemas defined by this keyword's value. | -| [multipleOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.1) | integer/number | A numeric instance is only valid if division by this keyword's value results in an integer. | -| [maximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.2) | integer/number | If the instance is a number, then this keyword validates only if the instance is less than or exactly equal to "maximum". | -| [exclusiveMaximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.3) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly less than (not equal to) "exclusiveMaximum". | -| [minimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.4) | integer/number | If the instance is a number, then this keyword validates only if the instance is greater than or exactly equal to "minimum". | -| [exclusiveMinimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.5) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly greater than (not equal to) "exclusiveMinimum". | -| [maxLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.6) | string | Limit the unicode character length of a string. | -| [minLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7) | string | Minimum length of a string. | -| [pattern](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.8) | string | A regular expression without delimiters. You can add a custom error message with the `x-patternMessageCode` field. | -| [items](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.9) | array | Ony supports a single schema. | -| [maxItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.11) | array | Limit the number of items in an array. | -| [minItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.12) | array | Minimum number of items in an array. | -| [uniqueItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.4.5) | array | All items must be unique. | -| [maxProperties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.5.1) | object | Limit the number of properties on an object. | -| [minProperties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.5.2) | object | Minimum number of properties on an object. | -| [additionalProperties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.5.6) | object | Validate additional properties against a schema. Can also be **true** to always validate. | -| [required](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.17) | object | Names of required object properties. | -| [properties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.18) | object | Specify schemas for object properties. | -| [enum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.23) | any | Specify an array of valid values. | -| [type](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25) | any | Specify a type of an array of types to validate a value. | -| [default](http://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3) | object | Applies to a schema that is in an object property. | -| [format](http://json-schema.org/latest/json-schema-validation.html#rfc.section.8.3) | string | Support for date-time, email, ipv4, ipv6, ip, uri. | -| [oneOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7.3) | object | Works with the `discriminator` property to validate against a dynamic schema. | - -## OpenAPI Schema Support - -OpenAPI defines some extended properties that are applied during validation. - -| Property | Type | Notes | -| -------- | ---- | ----- | -| nullable | boolean | If a field is nullable then it can also take the value **null**. | -| readOnly | boolean | Relevant only for Schema "properties" definitions. Declares the property as "read only". This means that it MAY be sent as part of a response but SHOULD NOT be sent as part of the request. If the property is marked as readOnly being true and is in the required list, the required will take effect on the response only. | -| writeOnly | boolean | Relevant only for Schema "properties" definitions. Declares the property as "write only". Therefore, it MAY be sent as part of a request but SHOULD NOT be sent as part of the response. If the property is marked as writeOnly being true and is in the required list, the required will take effect on the request only. | -| [discriminator](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#discriminatorObject) | object | Validate against a dynamic schema based on a property value. | +The **Schema** object is a wrapper for a [JSON Schema](http://json-schema.org/) array. This means that you can pass a valid JSON schema to Schema's constructor. The table below lists the JSON Schema properties that are supported. + +| Property | Type | Notes | +| ----------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| [multipleOf](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.1) | integer/number | A numeric instance is only valid if division by this keyword's value results in an integer. | +| [maximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.2) | integer/number | If the instance is a number, then this keyword validates only if the instance is less than or exactly equal to "maximum". | +| [exclusiveMaximum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.3) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly less than (not equal to) "exclusiveMaximum". | +| [minimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.4) | integer/number | If the instance is a number, then this keyword validates only if the instance is greater than or exactly equal to "minimum". | +| [exclusiveMinimum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.2.5) | integer/number | If the instance is a number, then the instance is valid only if it has a value strictly greater than (not equal to) "exclusiveMinimum". | +| [maxLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.6) | string | Limit the unicode character length of a string. | +| [minLength](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.7) | string | Minimum length of a string. | +| [pattern](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.8) | string | A regular expression without delimeters. | +| [items](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.9) | array | Ony supports a single schema. | +| [maxItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.11) | array | Limit the number of items in an array. | +| [minItems](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.12) | array | Minimum number of items in an array. | +| [required](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.17) | object | Names of required object properties. | +| [properties](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.18) | object | Specify schemas for object properties. | +| [enum](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.23) | any | Specify an array of valid values. | +| [type](http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25) | any | Specify a type of an array of types to validate a value. | +| [default](http://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3) | object | Applies to a schema that is in an object property. | +| [format](http://json-schema.org/latest/json-schema-validation.html#rfc.section.8.3) | string | Support for date-time, email, ipv4, ipv6, ip, uri. | diff --git a/src/Schema.php b/src/Schema.php index 02ec199..5f1ded6 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -38,11 +38,9 @@ class Schema implements \JsonSerializable, \ArrayAccess { 'string' => ['s', 'str'], 'number' => ['f', 'float'], 'boolean' => ['b', 'bool'], - - // Psuedo-types - 'timestamp' => ['ts'], // type: integer, format: timestamp - 'datetime' => ['dt'], // type: string, format: date-time - 'null' => ['n'], // Adds nullable: true + 'timestamp' => ['ts'], + 'datetime' => ['dt'], + 'null' => ['n'] ]; /** @@ -69,42 +67,292 @@ class Schema implements \JsonSerializable, \ArrayAccess { /** * @var string|Validation The name of the class or an instance that will be cloned. - * @deprecated */ private $validationClass = Validation::class; + + /// Methods /// + /** - * @var callable A callback is used to create validation objects. + * Initialize an instance of a new {@link Schema} class. + * + * @param array $schema The array schema to validate against. */ - private $validationFactory = [Validation::class, 'createValidation']; + public function __construct($schema = []) { + $this->schema = $schema; + $this->setFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE, true); + } /** - * @var callable + * Grab the schema's current description. + * + * @return string */ - private $refLookup; + public function getDescription() { + return isset($this->schema['description']) ? $this->schema['description'] : ''; + } - /// Methods /// + /** + * Set the description for the schema. + * + * @param string $description The new description. + * @throws \InvalidArgumentException Throws an exception when the provided description is not a string. + * @return Schema + */ + public function setDescription($description) { + if (is_string($description)) { + $this->schema['description'] = $description; + } else { + throw new \InvalidArgumentException("The description is not a valid string.", 500); + } + + return $this; + } /** - * Initialize an instance of a new {@link Schema} class. + * Get a schema field. * - * @param array $schema The array schema to validate against. - * @param callable $refLookup The function used to lookup references. + * @param string|array $path The JSON schema path of the field with parts separated by dots. + * @param mixed $default The value to return if the field isn't found. + * @return mixed Returns the field value or `$default`. */ - public function __construct(array $schema = [], callable $refLookup = null) { - $this->schema = $schema; + public function getField($path, $default = null) { + if (is_string($path)) { + $path = explode('.', $path); + } + + $value = $this->schema; + foreach ($path as $i => $subKey) { + if (is_array($value) && isset($value[$subKey])) { + $value = $value[$subKey]; + } elseif ($value instanceof Schema) { + return $value->getField(array_slice($path, $i), $default); + } else { + return $default; + } + } + return $value; + } + + /** + * Set a schema field. + * + * @param string|array $path The JSON schema path of the field with parts separated by dots. + * @param mixed $value The new value. + * @return $this + */ + public function setField($path, $value) { + if (is_string($path)) { + $path = explode('.', $path); + } + + $selection = &$this->schema; + foreach ($path as $i => $subSelector) { + if (is_array($selection)) { + if (!isset($selection[$subSelector])) { + $selection[$subSelector] = []; + } + } elseif ($selection instanceof Schema) { + $selection->setField(array_slice($path, $i), $value); + return $this; + } else { + $selection = [$subSelector => []]; + } + $selection = &$selection[$subSelector]; + } + + $selection = $value; + return $this; + } + + /** + * Get the ID for the schema. + * + * @return string + */ + public function getID() { + return isset($this->schema['id']) ? $this->schema['id'] : ''; + } + + /** + * Set the ID for the schema. + * + * @param string $id The new ID. + * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string. + * @return Schema + */ + public function setID($id) { + if (is_string($id)) { + $this->schema['id'] = $id; + } else { + throw new \InvalidArgumentException("The ID is not a valid string.", 500); + } + + return $this; + } + + /** + * Return the validation flags. + * + * @return int Returns a bitwise combination of flags. + */ + public function getFlags() { + return $this->flags; + } + + /** + * Set the validation flags. + * + * @param int $flags One or more of the **Schema::FLAG_*** constants. + * @return Schema Returns the current instance for fluent calls. + */ + public function setFlags($flags) { + if (!is_int($flags)) { + throw new \InvalidArgumentException('Invalid flags.', 500); + } + $this->flags = $flags; + + return $this; + } + + /** + * Whether or not the schema has a flag (or combination of flags). + * + * @param int $flag One or more of the **Schema::VALIDATE_*** constants. + * @return bool Returns **true** if all of the flags are set or **false** otherwise. + */ + public function hasFlag($flag) { + return ($this->flags & $flag) === $flag; + } + + /** + * Set a flag. + * + * @param int $flag One or more of the **Schema::VALIDATE_*** constants. + * @param bool $value Either true or false. + * @return $this + */ + public function setFlag($flag, $value) { + if ($value) { + $this->flags = $this->flags | $flag; + } else { + $this->flags = $this->flags & ~$flag; + } + return $this; + } + + /** + * Merge a schema with this one. + * + * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance. + * @return $this + */ + public function merge(Schema $schema) { + $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true); + return $this; + } + + /** + * Add another schema to this one. + * + * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information. + * + * @param Schema $schema The schema to add. + * @param bool $addProperties Whether to add properties that don't exist in this schema. + * @return $this + */ + public function add(Schema $schema, $addProperties = false) { + $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties); + return $this; + } + + /** + * The internal implementation of schema merging. + * + * @param array &$target The target of the merge. + * @param array $source The source of the merge. + * @param bool $overwrite Whether or not to replace values. + * @param bool $addProperties Whether or not to add object properties to the target. + * @return array + */ + private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) { + // We need to do a fix for required properties here. + if (isset($target['properties']) && !empty($source['required'])) { + $required = isset($target['required']) ? $target['required'] : []; + + if (isset($source['required']) && $addProperties) { + $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties'])); + $newRequired = array_intersect($source['required'], $newProperties); + + $required = array_merge($required, $newRequired); + } + } + + + foreach ($source as $key => $val) { + if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) { + if ($key === 'properties' && !$addProperties) { + // We just want to merge the properties that exist in the destination. + foreach ($val as $name => $prop) { + if (isset($target[$key][$name])) { + $targetProp = &$target[$key][$name]; + + if (is_array($targetProp) && is_array($prop)) { + $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties); + } elseif (is_array($targetProp) && $prop instanceof Schema) { + $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties); + } elseif ($overwrite) { + $targetProp = $prop; + } + } + } + } elseif (isset($val[0]) || isset($target[$key][0])) { + if ($overwrite) { + // This is a numeric array, so just do a merge. + $merged = array_merge($target[$key], $val); + if (is_string($merged[0])) { + $merged = array_keys(array_flip($merged)); + } + $target[$key] = $merged; + } + } else { + $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties); + } + } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) { + // Do nothing, we aren't replacing. + } else { + $target[$key] = $val; + } + } + + if (isset($required)) { + if (empty($required)) { + unset($target['required']); + } else { + $target['required'] = $required; + } + } + + return $target; + } + +// public function overlay(Schema $schema ) - $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */ - string $_) { - return null; - }; + /** + * Returns the internal schema array. + * + * @return array + * @see Schema::jsonSerialize() + */ + public function getSchemaArray() { + return $this->schema; } /** * Parse a short schema and return the associated schema. * * @param array $arr The schema array. - * @param mixed[] $args Constructor arguments for the schema instance. + * @param mixed ...$args Constructor arguments for the schema instance. * @return static Returns a new schema. */ public static function parse(array $arr, ...$args) { @@ -118,9 +366,9 @@ public static function parse(array $arr, ...$args) { * * @param array $arr The array to parse into a schema. * @return array The full schema array. - * @throws ParseException Throws an exception when an item in the schema is invalid. + * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid. */ - protected function parseInternal(array $arr): array { + protected function parseInternal(array $arr) { if (empty($arr)) { // An empty schema validates to anything. return []; @@ -156,17 +404,12 @@ protected function parseInternal(array $arr): array { /** * Parse a schema node. * - * @param array|Schema $node The node to parse. + * @param array $node The node to parse. * @param mixed $value Additional information from the node. - * @return array|\ArrayAccess Returns a JSON schema compatible node. - * @throws ParseException Throws an exception if there was a problem parsing the schema node. + * @return array Returns a JSON schema compatible node. */ private function parseNode($node, $value = null) { if (is_array($value)) { - if (is_array($node['type'])) { - trigger_error('Schemas with multiple types are deprecated.', E_USER_DEPRECATED); - } - // The value describes a bit more about the schema. switch ($node['type']) { case 'array': @@ -205,12 +448,13 @@ private function parseNode($node, $value = null) { $node['items'] = $this->parseInternal($node['items']); } elseif ($node['type'] === 'object' && isset($node['properties'])) { list($node['properties']) = $this->parseProperties($node['properties']); + } } if (is_array($node)) { if (!empty($node['allowNull'])) { - $node['nullable'] = true; + $node['type'] = array_merge((array)$node['type'], ['null']); } unset($node['allowNull']); @@ -227,9 +471,8 @@ private function parseNode($node, $value = null) { * * @param array $arr An object property schema. * @return array Returns a schema array suitable to be placed in the **properties** key of a schema. - * @throws ParseException Throws an exception if a property name cannot be determined for an array item. */ - private function parseProperties(array $arr): array { + private function parseProperties(array $arr) { $properties = []; $requiredProperties = []; foreach ($arr as $key => $value) { @@ -239,7 +482,7 @@ private function parseProperties(array $arr): array { $key = $value; $value = ''; } else { - throw new ParseException("Schema at position $key is not a valid parameter.", 500); + throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500); } } @@ -253,7 +496,7 @@ private function parseProperties(array $arr): array { $requiredProperties[] = $name; } } - return [$properties, $requiredProperties]; + return array($properties, $requiredProperties); } /** @@ -262,9 +505,9 @@ private function parseProperties(array $arr): array { * @param string $key The short parameter string to parse. * @param array $value An array of other information that might help resolve ambiguity. * @return array Returns an array in the form `[string name, array param, bool required]`. - * @throws ParseException Throws an exception if the short param is not in the correct format. + * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format. */ - public function parseShortParam(string $key, $value = []): array { + public function parseShortParam($key, $value = []) { // Is the parameter optional? if (substr($key, -1) === '?') { $required = false; @@ -274,36 +517,16 @@ public function parseShortParam(string $key, $value = []): array { } // Check for a type. - if (false !== ($pos = strrpos($key, ':'))) { - $name = substr($key, 0, $pos); - $typeStr = substr($key, $pos + 1); - - // Kludge for names with colons that are not specifying an array of a type. - if (isset($value['type']) && 'array' !== $this->getType($typeStr)) { - $name = $key; - $typeStr = ''; - } - } else { - $name = $key; - $typeStr = ''; - } + $parts = explode(':', $key); + $name = $parts[0]; $types = []; - $param = []; - if (!empty($typeStr)) { - $shortTypes = explode('|', $typeStr); + if (!empty($parts[1])) { + $shortTypes = explode('|', $parts[1]); foreach ($shortTypes as $alias) { $found = $this->getType($alias); if ($found === null) { - throw new ParseException("Unknown type '$alias'.", 500); - } elseif ($found === 'datetime') { - $param['format'] = 'date-time'; - $types[] = 'string'; - } elseif ($found === 'timestamp') { - $param['format'] = 'timestamp'; - $types[] = 'integer'; - } elseif ($found === 'null') { - $nullable = true; + throw new \InvalidArgumentException("Unknown type '$alias'", 500); } else { $types[] = $found; } @@ -312,27 +535,27 @@ public function parseShortParam(string $key, $value = []): array { if ($value instanceof Schema) { if (count($types) === 1 && $types[0] === 'array') { - $param += ['type' => $types[0], 'items' => $value]; + $param = ['type' => $types[0], 'items' => $value]; } else { $param = $value; } } elseif (isset($value['type'])) { - $param = $value + $param; + $param = $value; if (!empty($types) && $types !== (array)$param['type']) { $typesStr = implode('|', $types); $paramTypesStr = implode('|', (array)$param['type']); - throw new ParseException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500); + throw new \InvalidArgumentException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500); } } else { if (empty($types) && !empty($parts[1])) { - throw new ParseException("Invalid type {$parts[1]} for field $name.", 500); + throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500); } if (empty($types)) { - $param += ['type' => null]; + $param = ['type' => null]; } else { - $param += ['type' => count($types) === 1 ? $types[0] : $types]; + $param = ['type' => count($types) === 1 ? $types[0] : $types]; } // Parsed required strings have a minimum length of 1. @@ -341,1190 +564,181 @@ public function parseShortParam(string $key, $value = []): array { } } - if (!empty($nullable)) { - $param['nullable'] = true; - } - - if (is_array($param['type'])) { - trigger_error('Schemas with multiple types is deprecated.', E_USER_DEPRECATED); - } - return [$name, $param, $required]; } /** - * Look up a type based on its alias. - * - * @param string $alias The type alias or type name to lookup. - * @return mixed - */ - private function getType($alias) { - if (isset(self::$types[$alias])) { - return $alias; - } - foreach (self::$types as $type => $aliases) { - if (in_array($alias, $aliases, true)) { - return $type; - } - } - return null; - } - - /** - * Unescape a JSON reference segment. - * - * @param string $str The segment to unescapeRef. - * @return string Returns the unescaped string. - */ - public static function unescapeRef(string $str): string { - return str_replace(['~1', '~0'], ['/', '~'], $str); - } - - /** - * Explode a references into its individual parts. - * - * @param string $ref A JSON reference. - * @return string[] The individual parts of the reference. - */ - public static function explodeRef(string $ref): array { - return array_map([self::class, 'unescapeRef'], explode('/', $ref)); - } - - /** - * Grab the schema's current description. - * - * @return string - */ - public function getDescription(): string { - return $this->schema['description'] ?? ''; - } - - /** - * Set the description for the schema. - * - * @param string $description The new description. - * @return $this - */ - public function setDescription(string $description) { - $this->schema['description'] = $description; - return $this; - } - - /** - * Get the schema's title. - * - * @return string Returns the title. - */ - public function getTitle(): string { - return $this->schema['title'] ?? ''; - } - - /** - * Set the schema's title. - * - * @param string $title The new title. - */ - public function setTitle(string $title) { - $this->schema['title'] = $title; - } - - /** - * Get a schema field. - * - * @param string|array $path The JSON schema path of the field with parts separated by dots. - * @param mixed $default The value to return if the field isn't found. - * @return mixed Returns the field value or `$default`. - */ - public function getField($path, $default = null) { - if (is_string($path)) { - if (strpos($path, '.') !== false && strpos($path, '/') === false) { - trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED); - $path = explode('.', $path); - } else { - $path = explode('/', $path); - } - } - - $value = $this->schema; - foreach ($path as $i => $subKey) { - if (is_array($value) && isset($value[$subKey])) { - $value = $value[$subKey]; - } elseif ($value instanceof Schema) { - return $value->getField(array_slice($path, $i), $default); - } else { - return $default; - } - } - return $value; - } - - /** - * Set a schema field. - * - * @param string|array $path The JSON schema path of the field with parts separated by slashes. - * @param mixed $value The new value. - * @return $this - */ - public function setField($path, $value) { - if (is_string($path)) { - if (strpos($path, '.') !== false && strpos($path, '/') === false) { - trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED); - $path = explode('.', $path); - } else { - $path = explode('/', $path); - } - } - - $selection = &$this->schema; - foreach ($path as $i => $subSelector) { - if (is_array($selection)) { - if (!isset($selection[$subSelector])) { - $selection[$subSelector] = []; - } - } elseif ($selection instanceof Schema) { - $selection->setField(array_slice($path, $i), $value); - return $this; - } else { - $selection = [$subSelector => []]; - } - $selection = &$selection[$subSelector]; - } - - $selection = $value; - return $this; - } - - /** - * Return the validation flags. - * - * @return int Returns a bitwise combination of flags. - */ - public function getFlags(): int { - return $this->flags; - } - - /** - * Set the validation flags. - * - * @param int $flags One or more of the **Schema::FLAG_*** constants. - * @return Schema Returns the current instance for fluent calls. - */ - public function setFlags(int $flags) { - $this->flags = $flags; - - return $this; - } - - /** - * Set a flag. - * - * @param int $flag One or more of the **Schema::VALIDATE_*** constants. - * @param bool $value Either true or false. - * @return $this - */ - public function setFlag(int $flag, bool $value) { - if ($value) { - $this->flags = $this->flags | $flag; - } else { - $this->flags = $this->flags & ~$flag; - } - return $this; - } - - /** - * Merge a schema with this one. - * - * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance. - * @return $this - */ - public function merge(Schema $schema) { - $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true); - return $this; - } - - /** - * The internal implementation of schema merging. - * - * @param array $target The target of the merge. - * @param array $source The source of the merge. - * @param bool $overwrite Whether or not to replace values. - * @param bool $addProperties Whether or not to add object properties to the target. - * @return array - */ - private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) { - // We need to do a fix for required properties here. - if (isset($target['properties']) && !empty($source['required'])) { - $required = isset($target['required']) ? $target['required'] : []; - - if (isset($source['required']) && $addProperties) { - $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties'])); - $newRequired = array_intersect($source['required'], $newProperties); - - $required = array_merge($required, $newRequired); - } - } - - - foreach ($source as $key => $val) { - if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) { - if ($key === 'properties' && !$addProperties) { - // We just want to merge the properties that exist in the destination. - foreach ($val as $name => $prop) { - if (isset($target[$key][$name])) { - $targetProp = &$target[$key][$name]; - - if (is_array($targetProp) && is_array($prop)) { - $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties); - } elseif (is_array($targetProp) && $prop instanceof Schema) { - $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties); - } elseif ($overwrite) { - $targetProp = $prop; - } - } - } - } elseif (isset($val[0]) || isset($target[$key][0])) { - if ($overwrite) { - // This is a numeric array, so just do a merge. - $merged = array_merge($target[$key], $val); - if (is_string($merged[0])) { - $merged = array_keys(array_flip($merged)); - } - $target[$key] = $merged; - } - } else { - $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties); - } - } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) { - // Do nothing, we aren't replacing. - } else { - $target[$key] = $val; - } - } - - if (isset($required)) { - if (empty($required)) { - unset($target['required']); - } else { - $target['required'] = $required; - } - } - - return $target; - } - - /** - * Returns the internal schema array. - * - * @return array - * @see Schema::jsonSerialize() - */ - public function getSchemaArray(): array { - return $this->schema; - } - - /** - * Add another schema to this one. - * - * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information. - * - * @param Schema $schema The schema to add. - * @param bool $addProperties Whether to add properties that don't exist in this schema. - * @return $this - */ - public function add(Schema $schema, $addProperties = false) { - $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties); - return $this; - } - - /** - * Add a custom filter to change data before validation. + * Add a custom filter to change data before validation. * * @param string $fieldname The name of the field to filter, if any. * * If you are adding a filter to a deeply nested field then separate the path with dots. * @param callable $callback The callback to filter the field. - * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped. * @return $this */ - public function addFilter(string $fieldname, callable $callback, bool $validate = false) { - $fieldname = $this->parseFieldSelector($fieldname); - $this->filters[$fieldname][] = [$callback, $validate]; + public function addFilter($fieldname, callable $callback) { + $this->filters[$fieldname][] = $callback; return $this; } - /** - * Parse a nested field name selector. - * - * Field selectors should be separated by "/" characters, but may currently be separated by "." characters which - * triggers a deprecated error. - * - * @param string $field The field selector. - * @return string Returns the field selector in the correct format. - */ - private function parseFieldSelector(string $field): string { - if (strlen($field) === 0) { - return $field; - } - - if (strpos($field, '.') !== false) { - if (strpos($field, '/') === false) { - trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED); - - $parts = explode('.', $field); - $parts = @array_map([$this, 'parseFieldSelector'], $parts); // silence because error triggered already. - - $field = implode('/', $parts); - } - } elseif ($field === '[]') { - trigger_error('Field selectors with item selector "[]" must be converted to "items".', E_USER_DEPRECATED); - $field = 'items'; - } elseif (strpos($field, '/') === false && !in_array($field, ['items', 'additionalProperties'], true)) { - trigger_error("Field selectors must specify full schema paths. ($field)", E_USER_DEPRECATED); - $field = "/properties/$field"; - } - - if (strpos($field, '[]') !== false) { - trigger_error('Field selectors with item selector "[]" must be converted to "/items".', E_USER_DEPRECATED); - $field = str_replace('[]', '/items', $field); - } - - return ltrim($field, '/'); - } - - /** - * Add a custom filter for a schema format. - * - * Schemas can use the `format` property to specify a specific format on a field. Adding a filter for a format - * allows you to customize the behavior of that format. - * - * @param string $format The format to filter. - * @param callable $callback The callback used to filter values. - * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped. - * @return $this - */ - public function addFormatFilter(string $format, callable $callback, bool $validate = false) { - if (empty($format)) { - throw new \InvalidArgumentException('The filter format cannot be empty.', 500); - } - - $filter = "/format/$format"; - $this->filters[$filter][] = [$callback, $validate]; - - return $this; - } - - /** - * Require one of a given set of fields in the schema. - * - * @param array $required The field names to require. - * @param string $fieldname The name of the field to attach to. - * @param int $count The count of required items. - * @return Schema Returns `$this` for fluent calls. - */ - public function requireOneOf(array $required, string $fieldname = '', int $count = 1) { - $result = $this->addValidator( - $fieldname, - function ($data, ValidationField $field) use ($required, $count) { - // This validator does not apply to sparse validation. - if ($field->isSparse()) { - return true; - } - - $hasCount = 0; - $flattened = []; - - foreach ($required as $name) { - $flattened = array_merge($flattened, (array)$name); - - if (is_array($name)) { - // This is an array of required names. They all must match. - $hasCountInner = 0; - foreach ($name as $nameInner) { - if (array_key_exists($nameInner, $data)) { - $hasCountInner++; - } else { - break; - } - } - if ($hasCountInner >= count($name)) { - $hasCount++; - } - } elseif (array_key_exists($name, $data)) { - $hasCount++; - } - - if ($hasCount >= $count) { - return true; - } - } - - if ($count === 1) { - $message = 'One of {properties} are required.'; - } else { - $message = '{count} of {properties} are required.'; - } - - $field->addError('oneOfRequired', [ - 'messageCode' => $message, - 'properties' => $required, - 'count' => $count - ]); - return false; - } - ); - - return $result; - } - /** * Add a custom validator to to validate the schema. * - * @param string $fieldname The name of the field to validate, if any. - * - * If you are adding a validator to a deeply nested field then separate the path with dots. - * @param callable $callback The callback to validate with. - * @return Schema Returns `$this` for fluent calls. - */ - public function addValidator(string $fieldname, callable $callback) { - $fieldname = $this->parseFieldSelector($fieldname); - $this->validators[$fieldname][] = $callback; - return $this; - } - - /** - * Validate data against the schema and return the result. - * - * @param mixed $data The data to validate. - * @param array $options Validation options. See `Schema::validate()`. - * @return bool Returns true if the data is valid. False otherwise. - * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema. - */ - public function isValid($data, $options = []) { - try { - $this->validate($data, $options); - return true; - } catch (ValidationException $ex) { - return false; - } - } - - /** - * Validate data against the schema. - * - * @param mixed $data The data to validate. - * @param array $options Validation options. - * - * - **sparse**: Whether or not this is a sparse validation. - * @return mixed Returns a cleaned version of the data. - * @throws ValidationException Throws an exception when the data does not validate against the schema. - * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. - */ - public function validate($data, $options = []) { - if (is_bool($options)) { - trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED); - $options = ['sparse' => true]; - } - $options += ['sparse' => false]; - - - list($schema, $schemaPath) = $this->lookupSchema($this->schema, ''); - $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options); - - $clean = $this->validateField($data, $field); - - if (Invalid::isInvalid($clean) && $field->isValid()) { - // This really shouldn't happen, but we want to protect against seeing the invalid object. - $field->addError('invalid', ['messageCode' => 'The value is invalid.']); - } - - if (!$field->getValidation()->isValid()) { - throw new ValidationException($field->getValidation()); - } - - return $clean; - } - - /** - * Lookup a schema based on a schema node. - * - * The node could be a schema array, `Schema` object, or a schema reference. - * - * @param mixed $schema The schema node to lookup with. - * @param string $schemaPath The current path of the schema. - * @return array Returns an array with two elements: - * - Schema|array|\ArrayAccess The schema that was found. - * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas. - * @throws RefNotFoundException Throws an exception when a reference could not be found. - */ - private function lookupSchema($schema, string $schemaPath) { - if ($schema instanceof Schema) { - return [$schema, $schemaPath]; - } else { - $lookup = $this->getRefLookup(); - $visited = []; - - // Resolve any references first. - while (!empty($schema['$ref'])) { - $schemaPath = $schema['$ref']; - - if (isset($visited[$schemaPath])) { - throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508); - } - $visited[$schemaPath] = true; - - try { - $schema = call_user_func($lookup, $schemaPath); - } catch (\Exception $ex) { - throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex); - } - if ($schema === null) { - throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)"); - } - } - - return [$schema, $schemaPath]; - } - } - - /** - * Get the function used to resolve `$ref` lookups. - * - * @return callable Returns the current `$ref` lookup. - */ - public function getRefLookup(): callable { - return $this->refLookup; - } - - /** - * Set the function used to resolve `$ref` lookups. - * - * The function should have the following signature: - * - * ```php - * function(string $ref): array|Schema|null { - * ... - * } - * ``` - * The function should take a string reference and return a schema array, `Schema` or **null**. - * - * @param callable $refLookup The new lookup function. - * @return $this - */ - public function setRefLookup(callable $refLookup) { - $this->refLookup = $refLookup; - return $this; - } - - /** - * Create a new validation instance. - * - * @return Validation Returns a validation object. - */ - protected function createValidation(): Validation { - return call_user_func($this->getValidationFactory()); - } - - /** - * Get factory used to create validation objects. - * - * @return callable Returns the current factory. - */ - public function getValidationFactory(): callable { - return $this->validationFactory; - } - - /** - * Set the factory used to create validation objects. - * - * @param callable $validationFactory The new factory. - * @return $this - */ - public function setValidationFactory(callable $validationFactory) { - $this->validationFactory = $validationFactory; - $this->validationClass = null; - return $this; - } - - /** - * Validate a field. - * - * @param mixed $value The value to validate. - * @param ValidationField $field A validation object to add errors to. - * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value - * is completely invalid. - * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. - */ - protected function validateField($value, ValidationField $field) { - $validated = false; - $result = $value = $this->filterField($value, $field, $validated); - - if ($validated) { - return $result; - } elseif ($field->getField() instanceof Schema) { - try { - $result = $field->getField()->validate($value, $field->getOptions()); - } catch (ValidationException $ex) { - // The validation failed, so merge the validations together. - $field->getValidation()->merge($ex->getValidation(), $field->getName()); - } - } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && ($field->val('nullable') || $field->hasType('null'))) { - $result = null; - } else { - // Look for a discriminator. - if (!empty($field->val('discriminator'))) { - $field = $this->resolveDiscriminator($value, $field); - } - - if ($field !== null) { - if($field->hasAllOf()) { - $result = $this->validateAllOf($value, $field); - } else { - // Validate the field's type. - $type = $field->getType(); - if (is_array($type)) { - $result = $this->validateMultipleTypes($value, $type, $field); - } else { - $result = $this->validateSingleType($value, $type, $field); - } - - if (Invalid::isValid($result)) { - $result = $this->validateEnum($result, $field); - } - } - } else { - $result = Invalid::value(); - } - } - - // Validate a custom field validator. - if (Invalid::isValid($result)) { - $this->callValidators($result, $field); - } - - return $result; - } - - /** - * Filter a field's value using built in and custom filters. - * - * @param mixed $value The original value of the field. - * @param ValidationField $field The field information for the field. - * @param bool $validated Whether or not a filter validated the value. - * @return mixed Returns the filtered field or the original field value if there are no filters. - */ - private function filterField($value, ValidationField $field, bool &$validated = false) { - // Check for limited support for Open API style. - if (!empty($field->val('style')) && is_string($value)) { - $doFilter = true; - if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) { - $doFilter = false; - } elseif (($field->hasType('integer') || $field->hasType('number')) && is_numeric($value)) { - $doFilter = false; - } - - if ($doFilter) { - switch ($field->val('style')) { - case 'form': - $value = explode(',', $value); - break; - case 'spaceDelimited': - $value = explode(' ', $value); - break; - case 'pipeDelimited': - $value = explode('|', $value); - break; - } - } - } - - $value = $this->callFilters($value, $field, $validated); - - return $value; - } - - /** - * Call all of the filters attached to a field. - * - * @param mixed $value The field value being filtered. - * @param ValidationField $field The validation object. - * @param bool $validated Whether or not a filter validated the field. - * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned. - */ - private function callFilters($value, ValidationField $field, bool &$validated = false) { - // Strip array references in the name except for the last one. - $key = $field->getSchemaPath(); - if (!empty($this->filters[$key])) { - foreach ($this->filters[$key] as list($filter, $validate)) { - $value = call_user_func($filter, $value, $field); - $validated |= $validate; - - if (Invalid::isInvalid($value)) { - return $value; - } - } - } - $key = '/format/'.$field->val('format'); - if (!empty($this->filters[$key])) { - foreach ($this->filters[$key] as list($filter, $validate)) { - $value = call_user_func($filter, $value, $field); - $validated |= $validate; - - if (Invalid::isInvalid($value)) { - return $value; - } - } - } - - return $value; - } - - /** - * Validate a field against multiple basic types. - * - * The first validation that passes will be returned. If no type can be validated against then validation will fail. - * - * @param mixed $value The value to validate. - * @param string[] $types The types to validate against. - * @param ValidationField $field Contains field and validation information. - * @return mixed Returns the valid value or `Invalid`. - * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. - * @deprecated Multiple types are being removed next version. - */ - private function validateMultipleTypes($value, array $types, ValidationField $field) { - trigger_error('Multiple schema types are deprecated.', E_USER_DEPRECATED); - - // First check for an exact type match. - switch (gettype($value)) { - case 'boolean': - if (in_array('boolean', $types)) { - $singleType = 'boolean'; - } - break; - case 'integer': - if (in_array('integer', $types)) { - $singleType = 'integer'; - } elseif (in_array('number', $types)) { - $singleType = 'number'; - } - break; - case 'double': - if (in_array('number', $types)) { - $singleType = 'number'; - } elseif (in_array('integer', $types)) { - $singleType = 'integer'; - } - break; - case 'string': - if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) { - $singleType = 'datetime'; - } elseif (in_array('string', $types)) { - $singleType = 'string'; - } - break; - case 'array': - if (in_array('array', $types) && in_array('object', $types)) { - $singleType = isset($value[0]) || empty($value) ? 'array' : 'object'; - } elseif (in_array('object', $types)) { - $singleType = 'object'; - } elseif (in_array('array', $types)) { - $singleType = 'array'; - } - break; - case 'NULL': - if (in_array('null', $types)) { - $singleType = $this->validateSingleType($value, 'null', $field); - } - break; - } - if (!empty($singleType)) { - return $this->validateSingleType($value, $singleType, $field); - } - - // Clone the validation field to collect errors. - $typeValidation = new ValidationField(new Validation(), $field->getField(), '', '', $field->getOptions()); - - // Try and validate against each type. - foreach ($types as $type) { - $result = $this->validateSingleType($value, $type, $typeValidation); - if (Invalid::isValid($result)) { - return $result; - } - } - - // Since we got here the value is invalid. - $field->merge($typeValidation->getValidation()); - return Invalid::value(); - } - - /** - * Validate a field against a single type. - * - * @param mixed $value The value to validate. - * @param string $type The type to validate against. - * @param ValidationField $field Contains field and validation information. - * @return mixed Returns the valid value or `Invalid`. - * @throws \InvalidArgumentException Throws an exception when `$type` is not recognized. - * @throws RefNotFoundException Throws an exception when internal validation has a reference that isn't found. - */ - protected function validateSingleType($value, string $type, ValidationField $field) { - switch ($type) { - case 'boolean': - $result = $this->validateBoolean($value, $field); - break; - case 'integer': - $result = $this->validateInteger($value, $field); - break; - case 'number': - $result = $this->validateNumber($value, $field); - break; - case 'string': - $result = $this->validateString($value, $field); - break; - case 'timestamp': - trigger_error('The timestamp type is deprecated. Use an integer with a format of timestamp instead.', E_USER_DEPRECATED); - $result = $this->validateTimestamp($value, $field); - break; - case 'datetime': - trigger_error('The datetime type is deprecated. Use a string with a format of date-time instead.', E_USER_DEPRECATED); - $result = $this->validateDatetime($value, $field); - break; - case 'array': - $result = $this->validateArray($value, $field); - break; - case 'object': - $result = $this->validateObject($value, $field); - break; - case 'null': - $result = $this->validateNull($value, $field); - break; - case '': - // No type was specified so we are valid. - $result = $value; - break; - default: - throw new \InvalidArgumentException("Unrecognized type $type.", 500); - } - return $result; - } - - /** - * Validate a boolean value. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return bool|Invalid Returns the cleaned value or invalid if validation fails. - */ - protected function validateBoolean($value, ValidationField $field) { - $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if ($value === null) { - $field->addTypeError($value, 'boolean'); - return Invalid::value(); - } - - return $value; - } - - /** - * Validate and integer. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return int|Invalid Returns the cleaned value or **null** if validation fails. - */ - protected function validateInteger($value, ValidationField $field) { - if ($field->val('format') === 'timestamp') { - return $this->validateTimestamp($value, $field); - } - - $result = filter_var($value, FILTER_VALIDATE_INT); - - if ($result === false) { - $field->addTypeError($value, 'integer'); - return Invalid::value(); - } - - $result = $this->validateNumberProperties($result, $field); - - return $result; - } - - /** - * Validate a unix timestamp. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The field being validated. - * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate. + * @param string $fieldname The name of the field to validate, if any. + * + * If you are adding a validator to a deeply nested field then separate the path with dots. + * @param callable $callback The callback to validate with. + * @return Schema Returns `$this` for fluent calls. */ - protected function validateTimestamp($value, ValidationField $field) { - if (is_numeric($value) && $value > 0) { - $result = (int)$value; - } elseif (is_string($value) && $ts = strtotime($value)) { - $result = $ts; - } else { - $field->addTypeError($value, 'timestamp'); - $result = Invalid::value(); - } - return $result; + public function addValidator($fieldname, callable $callback) { + $this->validators[$fieldname][] = $callback; + return $this; } /** - * Validate specific numeric validation properties. + * Require one of a given set of fields in the schema. * - * @param int|float $value The value to test. - * @param ValidationField $field Field information. - * @return int|float|Invalid Returns the number of invalid. + * @param array $required The field names to require. + * @param string $fieldname The name of the field to attach to. + * @param int $count The count of required items. + * @return Schema Returns `$this` for fluent calls. */ - private function validateNumberProperties($value, ValidationField $field) { - $count = $field->getErrorCount(); + public function requireOneOf(array $required, $fieldname = '', $count = 1) { + $result = $this->addValidator( + $fieldname, + function ($data, ValidationField $field) use ($required, $count) { + // This validator does not apply to sparse validation. + if ($field->isSparse()) { + return true; + } - if ($multipleOf = $field->val('multipleOf')) { - $divided = $value / $multipleOf; + $hasCount = 0; + $flattened = []; - if ($divided != round($divided)) { - $field->addError('multipleOf', ['messageCode' => 'The value must be a multiple of {multipleOf}.', 'multipleOf' => $multipleOf]); - } - } + foreach ($required as $name) { + $flattened = array_merge($flattened, (array)$name); - if ($maximum = $field->val('maximum')) { - $exclusive = $field->val('exclusiveMaximum'); + if (is_array($name)) { + // This is an array of required names. They all must match. + $hasCountInner = 0; + foreach ($name as $nameInner) { + if (array_key_exists($nameInner, $data)) { + $hasCountInner++; + } else { + break; + } + } + if ($hasCountInner >= count($name)) { + $hasCount++; + } + } elseif (array_key_exists($name, $data)) { + $hasCount++; + } - if ($value > $maximum || ($exclusive && $value == $maximum)) { - if ($exclusive) { - $field->addError('maximum', ['messageCode' => 'The value must be less than {maximum}.', 'maximum' => $maximum]); - } else { - $field->addError('maximum', ['messageCode' => 'The value must be less than or equal to {maximum}.', 'maximum' => $maximum]); + if ($hasCount >= $count) { + return true; + } } - } - } - if ($minimum = $field->val('minimum')) { - $exclusive = $field->val('exclusiveMinimum'); - - if ($value < $minimum || ($exclusive && $value == $minimum)) { - if ($exclusive) { - $field->addError('minimum', ['messageCode' => 'The value must be greater than {minimum}.', 'minimum' => $minimum]); + if ($count === 1) { + $message = 'One of {required} are required.'; } else { - $field->addError('minimum', ['messageCode' => 'The value must be greater than or equal to {minimum}.', 'minimum' => $minimum]); + $message = '{count} of {required} are required.'; } - } - } - - return $field->getErrorCount() === $count ? $value : Invalid::value(); - } - - /** - * Validate a float. - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return float|Invalid Returns a number or **null** if validation fails. - */ - protected function validateNumber($value, ValidationField $field) { - $result = filter_var($value, FILTER_VALIDATE_FLOAT); - if ($result === false) { - $field->addTypeError($value, 'number'); - return Invalid::value(); - } - $result = $this->validateNumberProperties($result, $field); + $field->addError('missingField', [ + 'messageCode' => $message, + 'required' => $required, + 'count' => $count + ]); + return false; + } + ); return $result; } /** - * Validate a string. + * Validate data against the schema. * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return string|Invalid Returns the valid string or **null** if validation fails. + * @param mixed $data The data to validate. + * @param bool $sparse Whether or not this is a sparse validation. + * @return mixed Returns a cleaned version of the data. + * @throws ValidationException Throws an exception when the data does not validate against the schema. */ - protected function validateString($value, ValidationField $field) { - if ($field->val('format') === 'date-time') { - $result = $this->validateDatetime($value, $field); - return $result; - } - - if (is_string($value) || is_numeric($value)) { - $value = $result = (string)$value; - } else { - $field->addTypeError($value, 'string'); - return Invalid::value(); - } + public function validate($data, $sparse = false) { + $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse); - $strFn = $this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE) ? "mb_strlen" : "strlen"; - $strLen = $strFn($value); - if (($minLength = $field->val('minLength', 0)) > 0 && $strLen < $minLength) { - $field->addError( - 'minLength', - [ - 'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.', - 'minLength' => $minLength, - ] - ); - } + $clean = $this->validateField($data, $field, $sparse); - if (($maxLength = $field->val('maxLength', 0)) > 0 && $strLen > $maxLength) { - $field->addError( - 'maxLength', - [ - 'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.', - 'maxLength' => $maxLength, - 'overflow' => $strLen - $maxLength, - ] - ); + if (Invalid::isInvalid($clean) && $field->isValid()) { + // This really shouldn't happen, but we want to protect against seeing the invalid object. + $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]); } - if ($pattern = $field->val('pattern')) { - $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`'; - if (!preg_match($regex, $value)) { - $field->addError( - 'pattern', - [ - 'messageCode' => $field->val('x-patternMessageCode', 'The value doesn\'t match the required pattern {pattern}.'), - 'pattern' => $regex, - ] - ); - } - } - if ($format = $field->val('format')) { - $type = $format; - switch ($format) { - case 'date': - $result = $this->validateDatetime($result, $field); - if ($result instanceof \DateTimeInterface) { - $result = $result->format("Y-m-d\T00:00:00P"); - } - break; - case 'email': - $result = filter_var($result, FILTER_VALIDATE_EMAIL); - break; - case 'ipv4': - $type = 'IPv4 address'; - $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); - break; - case 'ipv6': - $type = 'IPv6 address'; - $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); - break; - case 'ip': - $type = 'IP address'; - $result = filter_var($result, FILTER_VALIDATE_IP); - break; - case 'uri': - $type = 'URL'; - $result = filter_var($result, FILTER_VALIDATE_URL); - break; - default: - trigger_error("Unrecognized format '$format'.", E_USER_NOTICE); - } - if ($result === false) { - $field->addError('format', [ - 'format' => $format, - 'formatCode' => $type, - 'value' => $value, - 'messageCode' => '{value} is not a valid {formatCode}.' - ]); - } + if (!$field->getValidation()->isValid()) { + throw new ValidationException($field->getValidation()); } - if ($field->isValid()) { - return $result; - } else { - return Invalid::value(); - } + return $clean; } /** - * Validate a date time. + * Validate data against the schema and return the result. * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid. + * @param mixed $data The data to validate. + * @param bool $sparse Whether or not to do a sparse validation. + * @return bool Returns true if the data is valid. False otherwise. */ - protected function validateDatetime($value, ValidationField $field) { - if ($value instanceof \DateTimeInterface) { - // do nothing, we're good - } elseif (is_string($value) && $value !== '' && !is_numeric($value)) { - try { - $dt = new \DateTimeImmutable($value); - if ($dt) { - $value = $dt; - } else { - $value = null; - } - } catch (\Throwable $ex) { - $value = Invalid::value(); - } - } elseif (is_int($value) && $value > 0) { - try { - $value = new \DateTimeImmutable('@'.(string)round($value)); - } catch (\Throwable $ex) { - $value = Invalid::value(); - } - } else { - $value = Invalid::value(); - } - - if (Invalid::isInvalid($value)) { - $field->addTypeError($value, 'date/time'); + public function isValid($data, $sparse = false) { + try { + $this->validate($data, $sparse); + return true; + } catch (ValidationException $ex) { + return false; } - return $value; } /** - * Recursively resolve allOf inheritance tree and return a merged resource specification + * Validate a field. * - * @param ValidationField $field The validation results to add. - * @return array Returns an array of merged specs. - * @throws ParseException Throws an exception if an invalid allof member is provided - * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found. + * @param mixed $value The value to validate. + * @param ValidationField $field A validation object to add errors to. + * @param bool $sparse Whether or not this is a sparse validation. + * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value + * is completely invalid. */ - private function resolveAllOfTree(ValidationField $field) { - $result = []; + protected function validateField($value, ValidationField $field, $sparse = false) { + $result = $value = $this->filterField($value, $field); - foreach($field->getAllOf() as $allof) { - if (!is_array($allof) || empty($allof)) { - throw new ParseException("Invalid allof member in {$field->getSchemaPath()}, array expected", 500); + if ($field->getField() instanceof Schema) { + try { + $result = $field->getField()->validate($value, $sparse); + } catch (ValidationException $ex) { + // The validation failed, so merge the validations together. + $field->getValidation()->merge($ex->getValidation(), $field->getName()); } - - list ($items, $schemaPath) = $this->lookupSchema($allof, $field->getSchemaPath()); - - $allOfValidation = new ValidationField( - $field->getValidation(), - $items, - '', - $schemaPath, - $field->getOptions() - ); - - if($allOfValidation->hasAllOf()) { - $result = array_replace_recursive($result, $this->resolveAllOfTree($allOfValidation)); + } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && $field->hasType('null')) { + $result = null; + } else { + // Validate the field's type. + $type = $field->getType(); + if (is_array($type)) { + $result = $this->validateMultipleTypes($value, $type, $field, $sparse); } else { - $result = array_replace_recursive($result, $items); + $result = $this->validateSingleType($value, $type, $field, $sparse); + } + if (Invalid::isValid($result)) { + $result = $this->validateEnum($result, $field); } } - return $result; - } - - /** - * Validate allof tree - * - * @param mixed $value The value to validate. - * @param ValidationField $field The validation results to add. - * @return array|Invalid Returns an array or invalid if validation fails. - * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found. - */ - private function validateAllOf($value, ValidationField $field) { - $allOfValidation = new ValidationField( - $field->getValidation(), - $this->resolveAllOfTree($field), - '', - $field->getSchemaPath(), - $field->getOptions() - ); + // Validate a custom field validator. + if (Invalid::isValid($result)) { + $this->callValidators($result, $field); + } - return $this->validateField($value, $allOfValidation); + return $result; } /** @@ -1532,59 +746,50 @@ private function validateAllOf($value, ValidationField $field) { * * @param mixed $value The value to validate. * @param ValidationField $field The validation results to add. + * @param bool $sparse Whether or not this is a sparse validation. * @return array|Invalid Returns an array or invalid if validation fails. - * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found. */ - protected function validateArray($value, ValidationField $field) { + protected function validateArray($value, ValidationField $field, $sparse = false) { if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) { - $field->addTypeError($value, 'array'); + $field->addTypeError('array'); return Invalid::value(); } else { if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) { $field->addError( 'minItems', [ - 'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.', + 'messageCode' => '{field} must contain at least {minItems} {minItems,plural,item}.', 'minItems' => $minItems, + 'status' => 422 ] ); } - if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) { - $field->addError( - 'maxItems', - [ - 'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.', - 'maxItems' => $maxItems, - ] - ); - } - - if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) { + if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) { $field->addError( - 'uniqueItems', + 'maxItems', [ - 'messageCode' => 'The array must contain unique items.', + 'messageCode' => '{field} must contain no more than {maxItems} {maxItems,plural,item}.', + 'maxItems' => $maxItems, + 'status' => 422 ] ); } if ($field->val('items') !== null) { - list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items'); + $result = []; // Validate each of the types. $itemValidation = new ValidationField( $field->getValidation(), - $items, + $field->val('items'), '', - $schemaPath, - $field->getOptions() + $sparse ); - $result = []; $count = 0; foreach ($value as $i => $item) { - $itemValidation->setName($field->getName()."/$i"); - $validItem = $this->validateField($item, $itemValidation); + $itemValidation->setName($field->getName()."[{$i}]"); + $validItem = $this->validateField($item, $itemValidation, $sparse); if (Invalid::isValid($validItem)) { $result[] = $validItem; } @@ -1601,72 +806,126 @@ protected function validateArray($value, ValidationField $field) { } /** - * Validate an object. + * Validate a boolean value. * * @param mixed $value The value to validate. * @param ValidationField $field The validation results to add. - * @return object|Invalid Returns a clean object or **null** if validation fails. - * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found. + * @return bool|Invalid Returns the cleaned value or invalid if validation fails. */ - protected function validateObject($value, ValidationField $field) { - if (!$this->isArray($value) || isset($value[0])) { - $field->addTypeError($value, 'object'); + protected function validateBoolean($value, ValidationField $field) { + $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + $field->addTypeError('boolean'); return Invalid::value(); - } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) { - // Validate the data against the internal schema. - $value = $this->validateProperties($value, $field); - } elseif (!is_array($value)) { - $value = $this->toObjectArray($value); } - if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) { - $field->addError( - 'maxProperties', - [ - 'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.', - 'maxItems' => $maxProperties, - ] - ); - } + return $value; + } - if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) { - $field->addError( - 'minProperties', - [ - 'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.', - 'minItems' => $minProperties, - ] - ); + /** + * Validate a date time. + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid. + */ + protected function validateDatetime($value, ValidationField $field) { + if ($value instanceof \DateTimeInterface) { + // do nothing, we're good + } elseif (is_string($value) && $value !== '' && !is_numeric($value)) { + try { + $dt = new \DateTimeImmutable($value); + if ($dt) { + $value = $dt; + } else { + $value = null; + } + } catch (\Exception $ex) { + $value = Invalid::value(); + } + } elseif (is_int($value) && $value > 0) { + $value = new \DateTimeImmutable('@'.(string)round($value)); + } else { + $value = Invalid::value(); } + if (Invalid::isInvalid($value)) { + $field->addTypeError('datetime'); + } return $value; } /** - * Check whether or not a value is an array or accessible like an array. + * Validate a float. * - * @param mixed $value The value to check. - * @return bool Returns **true** if the value can be used like an array or **false** otherwise. + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return float|Invalid Returns a number or **null** if validation fails. */ - private function isArray($value) { - return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable); + protected function validateNumber($value, ValidationField $field) { + $result = filter_var($value, FILTER_VALIDATE_FLOAT); + if ($result === false) { + $field->addTypeError('number'); + return Invalid::value(); + } + + $result = $this->validateNumberProperties($result, $field); + + return $result; + } + /** + * Validate and integer. + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return int|Invalid Returns the cleaned value or **null** if validation fails. + */ + protected function validateInteger($value, ValidationField $field) { + $result = filter_var($value, FILTER_VALIDATE_INT); + + if ($result === false) { + $field->addTypeError('integer'); + return Invalid::value(); + } + + $result = $this->validateNumberProperties($result, $field); + + return $result; + } + + /** + * Validate an object. + * + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @param bool $sparse Whether or not this is a sparse validation. + * @return object|Invalid Returns a clean object or **null** if validation fails. + */ + protected function validateObject($value, ValidationField $field, $sparse = false) { + if (!$this->isArray($value) || isset($value[0])) { + $field->addTypeError('object'); + return Invalid::value(); + } elseif (is_array($field->val('properties'))) { + // Validate the data against the internal schema. + $value = $this->validateProperties($value, $field, $sparse); + } elseif (!is_array($value)) { + $value = $this->toObjectArray($value); + } + return $value; } /** * Validate data against the schema and return the result. * - * @param array|\Traversable|\ArrayAccess $data The data to validate. + * @param array|\ArrayAccess $data The data to validate. * @param ValidationField $field This argument will be filled with the validation result. - * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types. + * @param bool $sparse Whether or not this is a sparse validation. + * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types. * or invalid if there are no valid properties. - * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found. */ - protected function validateProperties($data, ValidationField $field) { + protected function validateProperties($data, ValidationField $field, $sparse = false) { $properties = $field->val('properties', []); - $additionalProperties = $field->val('additionalProperties'); $required = array_flip($field->val('required', [])); - $isRequest = $field->isRequest(); - $isResponse = $field->isResponse(); if (is_array($data)) { $keys = array_keys($data); @@ -1676,55 +935,43 @@ protected function validateProperties($data, ValidationField $field) { $class = get_class($data); $clean = new $class; - if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) { + if ($clean instanceof \ArrayObject) { $clean->setFlags($data->getFlags()); $clean->setIteratorClass($data->getIteratorClass()); } } $keys = array_combine(array_map('strtolower', $keys), $keys); - $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions()); + $propertyField = new ValidationField($field->getValidation(), [], null, $sparse); // Loop through the schema fields and validate each one. foreach ($properties as $propertyName => $property) { - list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName)); - $propertyField ->setField($property) - ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/')) - ->setSchemaPath($schemaPath); + ->setName(ltrim($field->getName().".$propertyName", '.')); $lName = strtolower($propertyName); $isRequired = isset($required[$propertyName]); - // Check to strip this field if it is readOnly or writeOnly. - if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) { - unset($keys[$lName]); - continue; - } - - // Check for required fields. + // First check for required fields. if (!array_key_exists($lName, $keys)) { - if ($field->isSparse()) { + if ($sparse) { // Sparse validation can leave required fields out. } elseif ($propertyField->hasVal('default')) { $clean[$propertyName] = $propertyField->val('default'); } elseif ($isRequired) { - $propertyField->addError( - 'required', - ['messageCode' => '{property} is required.', 'property' => $propertyName] - ); + $propertyField->addError('missingField', ['messageCode' => '{field} is required.']); } } else { $value = $data[$keys[$lName]]; - if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) { + if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->hasType('null')) { if ($propertyField->getType() !== 'string' || $value === null) { continue; } } - $clean[$propertyName] = $this->validateField($value, $propertyField); + $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse); } unset($keys[$lName]); @@ -1732,36 +979,16 @@ protected function validateProperties($data, ValidationField $field) { // Look for extraneous properties. if (!empty($keys)) { - if ($additionalProperties) { - list($additionalProperties, $schemaPath) = $this->lookupSchema( - $additionalProperties, - $field->getSchemaPath().'/additionalProperties' - ); - - $propertyField = new ValidationField( - $field->getValidation(), - $additionalProperties, - '', - $schemaPath, - $field->getOptions() - ); - - foreach ($keys as $key) { - $propertyField - ->setName(ltrim($field->getName()."/$key", '/')); - - $valid = $this->validateField($data[$key], $propertyField); - if (Invalid::isValid($valid)) { - $clean[$key] = $valid; - } - } - } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) { - $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys)); + if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) { + $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys)); trigger_error($msg, E_USER_NOTICE); - } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) { - $field->addError('unexpectedProperties', [ - 'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.', + } + + if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) { + $field->addError('invalid', [ + 'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.', 'extra' => array_values($keys), + 'status' => 422 ]); } } @@ -1770,43 +997,161 @@ protected function validateProperties($data, ValidationField $field) { } /** - * Escape a JSON reference field. + * Validate a string. * - * @param string $field The reference field to escape. - * @return string Returns an escaped reference. + * @param mixed $value The value to validate. + * @param ValidationField $field The validation results to add. + * @return string|Invalid Returns the valid string or **null** if validation fails. */ - public static function escapeRef(string $field): string { - return str_replace(['~', '/'], ['~0', '~1'], $field); - } + protected function validateString($value, ValidationField $field) { + if (is_string($value) || is_numeric($value)) { + $value = $result = (string)$value; + } else { + $field->addTypeError('string'); + return Invalid::value(); + } - /** - * Whether or not the schema has a flag (or combination of flags). - * - * @param int $flag One or more of the **Schema::VALIDATE_*** constants. - * @return bool Returns **true** if all of the flags are set or **false** otherwise. - */ - public function hasFlag(int $flag): bool { - return ($this->flags & $flag) === $flag; +// $mbStrLen = mb_strlen($value); +// if (($minLength = $field->val('minLength', 0)) > 0 && $mbStrLen < $minLength) { +// $field->addError( +// 'minLength', +// [ +// 'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.', +// 'minLength' => $minLength, +// ] +// ); +// } +// +// if (($maxLength = $field->val('maxLength', 0)) > 0 && $mbStrLen > $maxLength) { +// $field->addError( +// 'maxLength', +// [ +// 'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.', +// 'maxLength' => $maxLength, +// 'overflow' => $mbStrLen - $maxLength, +// ] +// ); +// } +// + + + $mbStrLen = mb_strlen($value); + if (($minLength = $field->val('minLength', 0)) > 0 && $mbStrLen < $minLength) { + if (!empty($field->getName()) && $minLength === 1) { + $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]); + } else { + $field->addError( + 'minLength', + [ + 'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.', + 'minLength' => $minLength, + 'status' => 422 + ] + ); + } + } + if (($maxLength = $field->val('maxLength', 0)) > 0 && $mbStrLen > $maxLength) { + $field->addError( + 'maxLength', + [ + 'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.', + 'maxLength' => $maxLength, + 'overflow' => $mbStrLen - $maxLength, + 'status' => 422 + ] + ); + } + + $useLengthAsByteLength = !$this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE); + $maxByteLength = $field->val('maxByteLength', $useLengthAsByteLength ? $maxLength : null); + if ($maxByteLength !== null && $maxByteLength > 0) { + $byteStrLen = strlen($value); + if ($byteStrLen > $maxByteLength) { + $field->addError( + 'maxByteLength', + [ + 'messageCode' => '{field} is {overflow} {overflow,plural,byte,bytes} too long.', + 'maxLength' => $maxLength, + 'overflow' => $byteStrLen - $maxByteLength, + 'status' => 422, + ] + ); + } + } + + if ($pattern = $field->val('pattern')) { + $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`'; + + if (!preg_match($regex, $value)) { + $field->addError( + 'invalid', + [ + 'messageCode' => '{field} is in the incorrect format.', + 'status' => 422 + ] + ); + } + } + if ($format = $field->val('format')) { + $type = $format; + switch ($format) { + case 'date-time': + $result = $this->validateDatetime($result, $field); + if ($result instanceof \DateTimeInterface) { + $result = $result->format(\DateTime::RFC3339); + } + break; + case 'email': + $result = filter_var($result, FILTER_VALIDATE_EMAIL); + break; + case 'ipv4': + $type = 'IPv4 address'; + $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + break; + case 'ipv6': + $type = 'IPv6 address'; + $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + break; + case 'ip': + $type = 'IP address'; + $result = filter_var($result, FILTER_VALIDATE_IP); + break; + case 'uri': + $type = 'URI'; + $result = filter_var($result, FILTER_VALIDATE_URL); + break; + default: + trigger_error("Unrecognized format '$format'.", E_USER_NOTICE); + } + if ($result === false) { + $field->addTypeError($type); + } + } + + if ($field->isValid()) { + return $result; + } else { + return Invalid::value(); + } } /** - * Cast a value to an array. + * Validate a unix timestamp. * - * @param \Traversable $value The value to convert. - * @return array Returns an array. + * @param mixed $value The value to validate. + * @param ValidationField $field The field being validated. + * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate. */ - private function toObjectArray(\Traversable $value) { - $class = get_class($value); - if ($value instanceof \ArrayObject) { - return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass()); - } elseif ($value instanceof \ArrayAccess) { - $r = new $class; - foreach ($value as $k => $v) { - $r[$k] = $v; - } - return $r; + protected function validateTimestamp($value, ValidationField $field) { + if (is_numeric($value) && $value > 0) { + $result = (int)$value; + } elseif (is_string($value) && $ts = strtotime($value)) { + $result = $ts; + } else { + $field->addTypeError('timestamp'); + $result = Invalid::value(); } - return iterator_to_array($value); + return $result; } /** @@ -1820,7 +1165,7 @@ protected function validateNull($value, ValidationField $field) { if ($value === null) { return null; } - $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']); + $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]); return Invalid::value(); } @@ -1839,10 +1184,11 @@ protected function validateEnum($value, ValidationField $field) { if (!in_array($value, $enum, true)) { $field->addError( - 'enum', + 'invalid', [ - 'messageCode' => 'The value must be one of: {enum}.', + 'messageCode' => '{field} must be one of: {enum}.', 'enum' => $enum, + 'status' => 422 ] ); return Invalid::value(); @@ -1850,17 +1196,35 @@ protected function validateEnum($value, ValidationField $field) { return $value; } + /** + * Call all of the filters attached to a field. + * + * @param mixed $value The field value being filtered. + * @param ValidationField $field The validation object. + * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned. + */ + protected function callFilters($value, ValidationField $field) { + // Strip array references in the name except for the last one. + $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName()); + if (!empty($this->filters[$key])) { + foreach ($this->filters[$key] as $filter) { + $value = call_user_func($filter, $value, $field); + } + } + return $value; + } + /** * Call all of the validators attached to a field. * * @param mixed $value The field value being validated. * @param ValidationField $field The validation object to add errors. */ - private function callValidators($value, ValidationField $field) { + protected function callValidators($value, ValidationField $field) { $valid = true; // Strip array references in the name except for the last one. - $key = $field->getSchemaPath(); + $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName()); if (!empty($this->validators[$key])) { foreach ($this->validators[$key] as $validator) { $r = call_user_func($validator, $value, $field); @@ -1873,7 +1237,7 @@ private function callValidators($value, ValidationField $field) { // Add an error on the field if the validator hasn't done so. if (!$valid && $field->isValid()) { - $field->addError('invalid', ['messageCode' => 'The value is invalid.']); + $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]); } } @@ -1887,28 +1251,9 @@ private function callValidators($value, ValidationField $field) { * @link http://json-schema.org/ */ public function jsonSerialize() { - $seen = [$this]; - return $this->jsonSerializeInternal($seen); - } - - /** - * Return the JSON data for serialization with massaging for Open API. - * - * - Swap data/time & timestamp types for Open API types. - * - Turn recursive schema pointers into references. - * - * @param Schema[] $seen Schemas that have been seen during traversal. - * @return array Returns an array of data that `json_encode()` will recognize. - */ - private function jsonSerializeInternal(array $seen): array { - $fix = function ($schema) use (&$fix, $seen) { - if ($schema instanceof Schema) { - if (in_array($schema, $seen, true)) { - return ['$ref' => '#/components/schemas/'.($schema->getID() ?: '$no-id')]; - } else { - $seen[] = $schema; - return $schema->jsonSerializeInternal($seen); - } + $fix = function ($schema) use (&$fix) { + if ($schema instanceof Schema) { + return $schema->jsonSerialize(); } if (!empty($schema['type'])) { @@ -1947,14 +1292,30 @@ private function jsonSerializeInternal(array $seen): array { return $result; } + /** + * Look up a type based on its alias. + * + * @param string $alias The type alias or type name to lookup. + * @return mixed + */ + protected function getType($alias) { + if (isset(self::$types[$alias])) { + return $alias; + } + foreach (self::$types as $type => $aliases) { + if (in_array($alias, $aliases, true)) { + return $type; + } + } + return null; + } + /** * Get the class that's used to contain validation information. * * @return Validation|string Returns the validation class. - * @deprecated */ public function getValidationClass() { - trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED); return $this->validationClass; } @@ -1963,27 +1324,62 @@ public function getValidationClass() { * * @param Validation|string $class Either the name of a class or a class that will be cloned. * @return $this - * @deprecated */ public function setValidationClass($class) { - trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED); - if (!is_a($class, Validation::class, true)) { throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500); } - $this->setValidationFactory(function () use ($class) { - if ($class instanceof Validation) { - $result = clone $class; - } else { - $result = new $class; - } - return $result; - }); $this->validationClass = $class; return $this; } + /** + * Create a new validation instance. + * + * @return Validation Returns a validation object. + */ + protected function createValidation() { + $class = $this->getValidationClass(); + + if ($class instanceof Validation) { + $result = clone $class; + } else { + $result = new $class; + } + return $result; + } + + /** + * Check whether or not a value is an array or accessible like an array. + * + * @param mixed $value The value to check. + * @return bool Returns **true** if the value can be used like an array or **false** otherwise. + */ + private function isArray($value) { + return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable); + } + + /** + * Cast a value to an array. + * + * @param \Traversable $value The value to convert. + * @return array Returns an array. + */ + private function toObjectArray(\Traversable $value) { + $class = get_class($value); + if ($value instanceof \ArrayObject) { + return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass()); + } elseif ($value instanceof \ArrayAccess) { + $r = new $class; + foreach ($value as $k => $v) { + $r[$k] = $v; + } + return $r; + } + return iterator_to_array($value); + } + /** * Return a sparse version of this schema. * @@ -2033,24 +1429,40 @@ private function withSparseInternal($schema, \SplObjectStorage $schemas) { } /** - * Get the ID for the schema. + * Filter a field's value using built in and custom filters. * - * @return string + * @param mixed $value The original value of the field. + * @param ValidationField $field The field information for the field. + * @return mixed Returns the filtered field or the original field value if there are no filters. */ - public function getID(): string { - return $this->schema['id'] ?? ''; - } + private function filterField($value, ValidationField $field) { + // Check for limited support for Open API style. + if (!empty($field->val('style')) && is_string($value)) { + $doFilter = true; + if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) { + $doFilter = false; + } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) { + $doFilter = false; + } - /** - * Set the ID for the schema. - * - * @param string $id The new ID. - * @return $this - */ - public function setID(string $id) { - $this->schema['id'] = $id; + if ($doFilter) { + switch ($field->val('style')) { + case 'form': + $value = explode(',', $value); + break; + case 'spaceDelimited': + $value = explode(' ', $value); + break; + case 'pipeDelimited': + $value = explode('|', $value); + break; + } + } + } - return $this; + $value = $this->callFilters($value, $field); + + return $value; } /** @@ -2097,108 +1509,172 @@ public function offsetUnset($offset) { } /** - * Resolve the schema attached to a discriminator. + * Validate a field against a single type. + * + * @param mixed $value The value to validate. + * @param string $type The type to validate against. + * @param ValidationField $field Contains field and validation information. + * @param bool $sparse Whether or not this should be a sparse validation. + * @return mixed Returns the valid value or `Invalid`. + */ + protected function validateSingleType($value, $type, ValidationField $field, $sparse) { + switch ($type) { + case 'boolean': + $result = $this->validateBoolean($value, $field); + break; + case 'integer': + $result = $this->validateInteger($value, $field); + break; + case 'number': + $result = $this->validateNumber($value, $field); + break; + case 'string': + $result = $this->validateString($value, $field); + break; + case 'timestamp': + $result = $this->validateTimestamp($value, $field); + break; + case 'datetime': + $result = $this->validateDatetime($value, $field); + break; + case 'array': + $result = $this->validateArray($value, $field, $sparse); + break; + case 'object': + $result = $this->validateObject($value, $field, $sparse); + break; + case 'null': + $result = $this->validateNull($value, $field); + break; + case null: + // No type was specified so we are valid. + $result = $value; + break; + default: + throw new \InvalidArgumentException("Unrecognized type $type.", 500); + } + return $result; + } + + /** + * Validate a field against multiple basic types. * - * @param mixed $value The value to search for the discriminator. - * @param ValidationField $field The current node's schema information. - * @return ValidationField|null Returns the resolved schema or **null** if it can't be resolved. - * @throws ParseException Throws an exception if the discriminator isn't a string. - */ - private function resolveDiscriminator($value, ValidationField $field, array $visited = []) { - $propertyName = $field->val('discriminator')['propertyName'] ?? ''; - if (empty($propertyName) || !is_string($propertyName)) { - throw new ParseException("Invalid propertyName for discriminator at {$field->getSchemaPath()}", 500); + * The first validation that passes will be returned. If no type can be validated against then validation will fail. + * + * @param mixed $value The value to validate. + * @param string[] $types The types to validate against. + * @param ValidationField $field Contains field and validation information. + * @param bool $sparse Whether or not this should be a sparse validation. + * @return mixed Returns the valid value or `Invalid`. + */ + private function validateMultipleTypes($value, array $types, ValidationField $field, $sparse) { + // First check for an exact type match. + switch (gettype($value)) { + case 'boolean': + if (in_array('boolean', $types)) { + $singleType = 'boolean'; + } + break; + case 'integer': + if (in_array('integer', $types)) { + $singleType = 'integer'; + } elseif (in_array('number', $types)) { + $singleType = 'number'; + } + break; + case 'double': + if (in_array('number', $types)) { + $singleType = 'number'; + } elseif (in_array('integer', $types)) { + $singleType = 'integer'; + } + break; + case 'string': + if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) { + $singleType = 'datetime'; + } elseif (in_array('string', $types)) { + $singleType = 'string'; + } + break; + case 'array': + if (in_array('array', $types) && in_array('object', $types)) { + $singleType = isset($value[0]) || empty($value) ? 'array' : 'object'; + } elseif (in_array('object', $types)) { + $singleType = 'object'; + } elseif (in_array('array', $types)) { + $singleType = 'array'; + } + break; + case 'NULL': + if (in_array('null', $types)) { + $singleType = $this->validateSingleType($value, 'null', $field, $sparse); + } + break; + } + if (!empty($singleType)) { + return $this->validateSingleType($value, $singleType, $field, $sparse); } - $propertyFieldName = ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'); + // Clone the validation field to collect errors. + $typeValidation = new ValidationField(new Validation(), $field->getField(), '', $sparse); - // Do some basic validation checking to see if we can even look at the property. - if (!$this->isArray($value)) { - $field->addTypeError($value, 'object'); - return null; - } elseif (empty($value[$propertyName])) { - $field->getValidation()->addError( - $propertyFieldName, - 'required', - ['messageCode' => '{property} is required.', 'property' => $propertyName] - ); - return null; + // Try and validate against each type. + foreach ($types as $type) { + $result = $this->validateSingleType($value, $type, $typeValidation, $sparse); + if (Invalid::isValid($result)) { + return $result; + } } - $propertyValue = $value[$propertyName]; - if (!is_string($propertyValue)) { - $field->getValidation()->addError( - $propertyFieldName, - 'type', - [ - 'type' => 'string', - 'value' => is_scalar($value) ? $value : null, - 'messageCode' => is_scalar($value) ? "{value} is not a valid string." : "The value is not a valid string." - ] - ); - return null; - } + // Since we got here the value is invalid. + $field->merge($typeValidation->getValidation()); + return Invalid::value(); + } + + /** + * Validate specific numeric validation properties. + * + * @param int|float $value The value to test. + * @param ValidationField $field Field information. + * @return int|float|Invalid Returns the number of invalid. + */ + private function validateNumberProperties($value, ValidationField $field) { + $count = $field->getErrorCount(); - $mapping = $field->val('discriminator')['mapping'] ?? ''; - if (isset($mapping[$propertyValue])) { - $ref = $mapping[$propertyValue]; + if ($multipleOf = $field->val('multipleOf')) { + $divided = $value / $multipleOf; - if (strpos($ref, '#') === false) { - $ref = '#/components/schemas/'.self::escapeRef($ref); + if ($divided != round($divided)) { + $field->addError('multipleOf', ['messageCode' => '{field} is not a multiple of {multipleOf}.', 'status' => 422, 'multipleOf' => $multipleOf]); } - } else { - // Don't let a property value provide its own ref as that may pose a security concern.. - $ref = '#/components/schemas/'.self::escapeRef($propertyValue); } - // Validate the reference against the oneOf constraint. - $oneOf = $field->val('oneOf', []); - if (!empty($oneOf) && !in_array(['$ref' => $ref], $oneOf)) { - $field->getValidation()->addError( - $propertyFieldName, - 'oneOf', - [ - 'type' => 'string', - 'value' => is_scalar($propertyValue) ? $propertyValue : null, - 'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option." - ] - ); - return null; - } + if ($maximum = $field->val('maximum')) { + $exclusive = $field->val('exclusiveMaximum'); - try { - // Lookup the schema. - $visited[$field->getSchemaPath()] = true; + if ($value > $maximum || ($exclusive && $value == $maximum)) { + if ($exclusive) { + $field->addError('maximum', ['messageCode' => '{field} is greater than or equal to {maximum}.', 'status' => 422, 'maximum' => $maximum]); + } else { + $field->addError('maximum', ['messageCode' => '{field} is greater than {maximum}.', 'status' => 422, 'maximum' => $maximum]); + } - list($schema, $schemaPath) = $this->lookupSchema(['$ref' => $ref], $field->getSchemaPath()); - if (isset($visited[$schemaPath])) { - throw new RefNotFoundException('Cyclical ref.', 508); } + } + + if ($minimum = $field->val('minimum')) { + $exclusive = $field->val('exclusiveMinimum'); + + if ($value < $minimum || ($exclusive && $value == $minimum)) { + if ($exclusive) { + $field->addError('minimum', ['messageCode' => '{field} is greater than or equal to {minimum}.', 'status' => 422, 'minimum' => $minimum]); + } else { + $field->addError('minimum', ['messageCode' => '{field} is greater than {minimum}.', 'status' => 422, 'minimum' => $minimum]); + } - $result = new ValidationField( - $field->getValidation(), - $schema, - $field->getName(), - $schemaPath, - $field->getOptions() - ); - if (!empty($schema['discriminator'])) { - return $this->resolveDiscriminator($value, $result, $visited); - } else { - return $result; } - } catch (RefNotFoundException $ex) { - // Since this is a ref provided by the value it is technically a validation error. - $field->getValidation()->addError( - $propertyFieldName, - 'propertyName', - [ - 'type' => 'string', - 'value' => is_scalar($propertyValue) ? $propertyValue : null, - 'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option." - ] - ); - return null; } + + return $field->getErrorCount() === $count ? $value : Invalid::value(); } } diff --git a/tests/StringValidationTest.php b/tests/StringValidationTest.php index f5336ad..c85a8be 100644 --- a/tests/StringValidationTest.php +++ b/tests/StringValidationTest.php @@ -161,15 +161,15 @@ public function provideByteLengths() { return [ 'maxLength - short' => [['justLength' => '😱']], 'maxLength - equal' => [['justLength' => '😱😱😱😱']], - 'maxLength - long' => [['justLength' => '😱😱😱😱😱'], '1 character too long'], + 'maxLength - long' => [['justLength' => '😱😱😱😱😱'], '1 characters too long'], 'byteLength - short' => [['justByteLength' => '😱']], 'byteLength - equal' => [['justByteLength' => '😱😱']], 'byteLength - long' => [['justByteLength' => '😱😱a'], '1 byte too long'], 'mixedLengths - short' => [['mixedLengths' => '😱']], 'mixedLengths - equal' => [['mixedLengths' => '😱aa']], 'mixedLengths - long bytes' => [['mixedLengths' => '😱😱'], '2 bytes too long'], - 'mixedLengths - long chars' => [['mixedLengths' => 'aaaaa'], '1 character too long'], - 'mixedLengths - long chars - long bytes' => [['mixedLengths' => '😱😱😱😱😱'], ["1 character too long", "14 bytes too long."]], + 'mixedLengths - long chars' => [['mixedLengths' => 'aaaaa'], '1 characters too long'], + 'mixedLengths - long chars - long bytes' => [['mixedLengths' => '😱😱😱😱😱'], ["1 characters too long", "14 bytes too long."]], 'byteLength flag - short' => [['justLength' => '😱'], null, true], 'byteLength flag - long' => [['justLength' => '😱😱😱😱'], '12 bytes too long', true], 'byteLength property is preferred over byte length flag' => [['mixedLengths' => '😱😱'], '2 bytes too long', true] From a64baad37787e7fdcd2ed5c2c9f609de1f93516e Mon Sep 17 00:00:00 2001 From: Adam Charron Date: Wed, 15 Dec 2021 12:17:23 -0500 Subject: [PATCH 3/3] Remove commented code --- src/Schema.php | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/Schema.php b/src/Schema.php index 5f1ded6..a22f108 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -1011,30 +1011,6 @@ protected function validateString($value, ValidationField $field) { return Invalid::value(); } -// $mbStrLen = mb_strlen($value); -// if (($minLength = $field->val('minLength', 0)) > 0 && $mbStrLen < $minLength) { -// $field->addError( -// 'minLength', -// [ -// 'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.', -// 'minLength' => $minLength, -// ] -// ); -// } -// -// if (($maxLength = $field->val('maxLength', 0)) > 0 && $mbStrLen > $maxLength) { -// $field->addError( -// 'maxLength', -// [ -// 'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.', -// 'maxLength' => $maxLength, -// 'overflow' => $mbStrLen - $maxLength, -// ] -// ); -// } -// - - $mbStrLen = mb_strlen($value); if (($minLength = $field->val('minLength', 0)) > 0 && $mbStrLen < $minLength) { if (!empty($field->getName()) && $minLength === 1) {