diff --git a/docs/object-simplifications.md b/docs/object-simplifications.md new file mode 100644 index 0000000..c762ee6 --- /dev/null +++ b/docs/object-simplifications.md @@ -0,0 +1,97 @@ +# How The Reader Simplifies Your OpenAPI + +Besides simply parsing your OpenAPI into `Validated` objects, +the reader is designed to _simplify the developer experience_ of other OpenAPI tools. + +## Standard Simplifications + +### Never Worry About Uninitialized Properties +Properties should be safe to access: +All properties have a value. +`null` represents an omitted field _if_ no other value can be safely assumed. + +### Strong Typehints +Data structures should be easily discoverable. +All properties should have strong typehints. + +## Opinionated Simplifications + +### Narrow Schemas To False If Impossible To Pass +The `false` boolean schema explicitly states any input will fail. +The Reader will narrow schemas to `false` if it is proven impossible to pass. +This optimizes code that may otherwise validate input that will always fail. + +#### Enum Specified Empty +Any schema specifying an empty `enum`, is narrowed to the `false` boolean schema. + +If `enum` is specified, it contains the exhaustive list of valid values. +If `enum` is specified as an empty array, there are no valid values. + +#### Enum Without A Valid Value +If a schema specifies `enum` without a value that passes the rest of the schema; +it is impossible to pass, it will be narrowed to the `false` boolean schema. + +### Narrow Typehints +Typehints are narrowed if it has no impact on expressiveness. + +#### AllOf, AnyOf and OneOf Are Always Arrays + +`allOf`, `anyOf` and `oneOf` can express two things: +1. There are subschemas +2. There are not + +To express there are no subschemas, the value is omitted. + +As such, the Reader structures `allOf`, `anyOf` and `oneOf` in two ways: +1. A non-empty array +2. An empty array + +Though these keywords are not allowed to be empty, +[The Reader allows it](validation-deviations.md#allof-anyof-and-oneof-can-be-empty) +for the sake of simplicity. + +This simplifies code that loops through subschemas. + +#### String Metadata Is Always String + +Optional metadata is expressed in two ways: +1. There is data +2. There is not + +As such, the Reader structures metadata in two ways: +1. A string containing non-whitespace characters +2. An empty string `''` + +When accessing string metadata only one check is necessary: + +```php +if ($metadata !== '') { + // do something... +} +``` + +### Combined Fields +Data is combined, if and only if, it has no impact on expressiveness. + +#### Maximum|Minimum are combined with ExclusiveMaximum|ExclusiveMinimum + +[//]: # (TODO explain how they are combined for 3.0 and in 3.1 we take the more restrictive keyword) + +A numerical limit can only be expressed in three ways: +- There is no limit +- There is an inclusive limit +- There is an exclusive limit + +As such the Reader combines the relevant keywords into: +- `Limit|null $maximum`. +- `Limit|null $minimum`. + +Where `Limit` has two properties: +- `float|int $limit` +- `bool $exclusive` + +#### Const Overrides Enum in 3.1 + +[//]: # (TODO Flesh out a bit) + +The more restrictive keyword takes precedence. diff --git a/docs/validation-deviations.md b/docs/validation-deviations.md new file mode 100644 index 0000000..bc3dbb8 --- /dev/null +++ b/docs/validation-deviations.md @@ -0,0 +1,84 @@ +# Validation And How It Deviates From OpenAPI Specification +This page documents where validation deviates from the OpenAPI Specification. + +## Stricter Requirements +It is stricter when it comes to providing an unambiguous specification. +This helps to avoid confusion as well as simplify development for other OpenAPI tools. + +### OperationId Is Required + +Operations MUST have an [operationId](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-8). + +`operationId` is a unique string used to identify an operation. +By making it required, it serves as a _reliable_ method of identification. + +### Query Strings Must Be Unambiguous + +Operations MUST NOT use more than one _ambiguous parameter_. + +In this context, an _ambiguous parameter_ is defined as being `in:query` with one of the following combinations: +- `type:object` with `style:form` and `explode:true` +- `type:object` or `type:array` with `style:spaceDelimited` +- `type:object` or `type:array` with `style:pipeDelimited` + +If everything else can be identified; then through a process of elimination, the ambiguous segment belongs to the _ambiguous parameter_ + +If an operation contains two or more _ambiguous parameters_, then there are multiple ways of interpreting the ambiguous segment. +This ambiguity means the query string cannot be resolved deterministically. +As such, it is not allowed. + +## Looser Requirements + +These requirements are looser than the OpenAPI Specification. +Where the OpenAPI Specification would be invalid, the reader will add a warning to the `Validated` object. + +### MultipleOf Can Be Negative + +Normally `multipleOf` MUST be a positive non-zero number. + +The Reader allows `multipleOf` to be any non-zero number. +A multiple of a negative number is also a multiple of its absolute value. +It's more confusing, but what is expressed is identical. + +Therefore, if a negative value is given: +- You will receive a Warning +- The absolute value will be used + +### AllOf, AnyOf and OneOf Can Be Empty + +Normally, `allOf`, `anyOf` and `oneOf` MUST be non-empty. + +The Reader allows them to be empty. +If any of these keywords are omitted, they are treated as empty arrays. + + +Therefore, if an empty array is given: +- You will receive a Warning +- An empty array will be used. + +If it is omitted: +- An empty array will be used. + +[This is done for simplicity](object-simplifications.md#allof-anyof-and-oneof-are-always-arrays). + +### Required Can Be Empty + +OpenAPI 3.0 states `required` MUST be non-empty. +OpenAPI 3.1 allows `required` to be empty and defaults to empty if omitted. + +The Reader always allows `required` to be empty and defaults to empty if omitted. +This allows us to narrow the typehint for `required` to always being an array. + +If an empty array is given: +- If your API is using 3.0, you will receive a Warning +- An empty array will be used + +### Required Can Contain Duplicates + +Normally `required` MUST contain unique values. + +The Reader allows `required` to contain duplicates. + +If a duplicate item is found: +- You will receive a Warning +- The duplicate will be removed. diff --git a/docs/validation.md b/docs/validation.md deleted file mode 100644 index 846cbc2..0000000 --- a/docs/validation.md +++ /dev/null @@ -1,58 +0,0 @@ -# Validation Performed By OpenAPI Reader - -## Additional Requirements For Membrane. - -### Specify an OperationId - -Membrane requires all Operations to set a [unique](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-8) `operationId`. - -This is used for identification of all available operations across your OpenAPI. - -### Unambiguous Query Strings - -For query parameters (i.e. `in:query`) with a `schema` that allows compound types i.e. `array` or `objects` -there are certain combinations of `style` and `explode` that do not use the parameter's `name`. - -These combinations are: -- `type:object` with `style:form` and `explode:true` -- `type:object` or `type:array` with `style:spaceDelimited` -- `type:object` or `type:array` with `style:pipeDelimited` - -If an operation only has one query parameter (i.e. `in:query`) then this is fine. Membrane can safely assume the entire string belongs to that one parameter. - -If an operation contains two query parameters, both of which do not use the parameter's name; Membrane cannot ascertain which parameter relates to which part of the query string. - -This ambiguity leads to multiple "correct" ways to interpret the query string. Making it impossible to safely assume Membrane has validated it. Therefore, only one parameter, with one of the above combinations, is allowed on any given Operation. - -## Version 3.0.X - -### OpenAPI Object - -- [An OpenAPI Object requires an `openapi` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields). -- [An OpenAPI Object requires an `info` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields). - - [The Info Object requires a `title`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-1). - - [The Info Object requires a `version`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-1). -- [All Path Items must be mapped to by their relative endpoint](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#paths-object). -### Path Item - -- [All Operations MUST be mapped to by a method](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-7). -- [Parameters must be unique. Uniqueness is defined by a combination of "name" and "in".](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-7) - -### Operation - -- [Parameters must be unique. Uniqueness is defined by a combination of "name" and "in".](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-8) - -### Parameter - -- A Parameter [MUST contain a `name` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). -- A Parameter [MUST contain an `in` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - `in` [MUST be set to `path`, `query`, `header` or `cookie`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - [if `in:path` then the Parameter MUST specify `required:true`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - if `style` is specified, [acceptable values depend on the value of `in`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#style-values). -- [A Parameter MUST contain a `schema` or `content`, but not both](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - if `content` is specified, it MUST contain exactly one Media Type - - A Parameter's MediaType MUST contain a schema. - -### Schema - -- [If allOf, anyOf or oneOf are set; They MUST not be empty](https://json-schema.org/draft/2020-12/json-schema-core#section-10.2). diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 2869e5f..b7043b6 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -4,6 +4,7 @@ namespace Membrane\OpenAPIReader\Exception; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use RuntimeException; @@ -312,11 +313,14 @@ public static function invalidType(Identifier $identifier, string $type): self return new self($message); } - public static function typeArrayInWrongVersion(Identifier $identifier): self - { + public static function keywordMustBeType( + Identifier $identifier, + string $keyword, + Type $type, + ): self { $message = <<value TEXT; return new self($message); @@ -356,19 +360,19 @@ public static function boolExclusiveMinMaxIn31( return new self($message); } - public static function keywordMustBeStrictlyPositiveNumber( + public static function keywordCannotBeZero( Identifier $identifier, string $keyword, ): self { $message = << */ - public null|string|array $type = null, + /** @var string[]|string|null */ + public array|string|null $type = null, /** @var array|null */ public array|null $enum = null, public Value|null $const = null, @@ -81,7 +81,7 @@ public function __construct( public array|null $anyOf = null, /** @var array|null */ public array|null $oneOf = null, - public Schema|null $not = null, + public bool|Schema|null $not = null, /** * Keywords for applying subschemas conditionally * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index 854df1f..8f98c3a 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -4,7 +4,9 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\Enum; +use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; +use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; enum Type: string { @@ -48,6 +50,15 @@ public static function valuesForVersion(OpenAPIVersion $version): array return array_map(fn($t) => $t->value, self::casesForVersion($version)); } + public static function fromVersion( + Identifier $identifier, + OpenAPIVersion $version, + string $type + ): self { + return self::tryFromVersion($version, $type) ?? + throw InvalidOpenAPI::invalidType($identifier, $type); + } + public static function tryFromVersion( OpenAPIVersion $version, string $type diff --git a/src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php b/src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php new file mode 100644 index 0000000..e29eeea --- /dev/null +++ b/src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php @@ -0,0 +1,46 @@ + */ + public readonly array $types; + + /** @var non-empty-list|null */ + public readonly array|null $enum; + public readonly Value|null $default; + + public readonly float|int|null $multipleOf; + public readonly Limit|null $maximum; + public readonly Limit|null $minimum; + + public readonly int|null $maxLength; + public readonly int $minLength; + public readonly string|null $pattern; + + public readonly Schema $items; + public readonly int|null $maxItems; + public readonly int $minItems; + public readonly bool $uniqueItems; + + public readonly int|null $maxProperties; + public readonly int $minProperties; + /** @var list */ + public readonly array $required; + /** @var array */ + public readonly array $properties; + public readonly bool|Schema $additionalProperties; + + /** @var list */ + public readonly array $allOf; + /** @var list */ + public readonly array $anyOf; + /** @var list */ + public readonly array $oneOf; + public readonly Schema $not; + + public readonly string $format; + + public readonly string $title; + public readonly string $description; + + /** @var Type[] */ + private readonly array $typesItCanBe; + + public function __construct( + Identifier $identifier, + Valid\Warnings $warnings, + Partial\Schema $schema + ) { + parent::__construct($identifier, $warnings); + + $this->types = $this->validateTypes($schema->type, $schema->nullable); + $this->enum = $this->reviewEnum($this->types, $schema->enum); + $this->default = $this->validateDefault($this->types, $schema->default); + + $this->multipleOf = $this->validateMultipleOf($schema->multipleOf); + $this->maximum = $this->validateMinMax( + 'maximum', + $schema->maximum, + 'exclusiveMaximum', + $schema->exclusiveMaximum, + ); + $this->minimum = $this->validateMinMax( + 'minimum', + $schema->minimum, + 'exclusiveMinimum', + $schema->exclusiveMinimum + ); + + $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength, false); + $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength, true); + $this->pattern = $schema->pattern; //TODO validatePattern is valid regex + + $this->items = $this->reviewItems($this->types, $schema->items); + $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems, false); + $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems, true); + $this->uniqueItems = $schema->uniqueItems; + + $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties, false); + $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties, true); + //TODO if a property is defined as a false schema AND required, that should be a warning + //TODO if property is false schema and required, and type must be object, the whole schema is the false schema + $this->required = $this->validateRequired($schema->required); + $this->properties = $this->validateProperties($schema->properties); + $this->additionalProperties = new Schema( + $this->appendedIdentifier('additionalProperties'), + $schema->additionalProperties, + ); + + + //TODO throw ShouldBeBooleanSchema::false if allOf contains false schema + $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); + $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); + $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); + $this->not = new Schema( + $this->appendedIdentifier('not'), + $schema->not ?? false, + ); + + $this->format = $this->formatMetadataString($schema->format); + + $this->title = $this->formatMetadataString($schema->title); + $this->description = $this->formatMetadataString($schema->description); + + $this->typesItCanBe = array_map( + fn($t) => Type::from($t), + $this->typesItCanBe() + ); + + if (empty($this->typesItCanBe)) { + $this->addWarning( + 'no data type can satisfy this schema', + Warning::IMPOSSIBLE_SCHEMA + ); + } + } + + /** @return string[] */ + public function typesItCanBe(): array + { + $possibilities = [array_merge( + Type::valuesForVersion(OpenAPIVersion::Version_3_0), + [Type::Null->value], + )]; + + if ($this->types !== []) { + $possibilities[] = array_map(fn($t) => $t->value, $this->types); + } + + if (!empty($this->allOf)) { + $possibilities[] = array_intersect(...array_map( + fn($s) => $s->typesItCanBe(), + $this->allOf, + )); + } + + if (!empty($this->anyOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->anyOf + ))); + } + + if (!empty($this->oneOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->oneOf + ))); + } + + return array_values(array_intersect(...$possibilities)); + } + + /** + * @param null|string|array $type + * @return list + */ + private function validateTypes( + null|string|array $type, + bool $nullable, + ): array { + if (isset($this->types)) { + return $this->types; + } + + if (empty($type)) { // If type is unspecified, nullable has no effect + return []; // So we can return immediately + } + + if (is_string($type)) { // In 3.0 "type" must be a string, a single item + $type = [$type]; + } elseif (count($type) > 1) { // We can amend arrays of 1 but no greater + throw InvalidOpenAPI::keywordMustBeType( + $this->getIdentifier(), + 'type', + Type::String, + ); + } + + $result = array_map( + fn($t) => Type::tryFromVersion(OpenAPIVersion::Version_3_0, $t) + ?? throw InvalidOpenAPI::invalidType($this->getIdentifier(), $t), + $type, + ); + + if ($nullable) { + $result[] = Type::Null; + } + + return $result; + } + + /** + * @param list $types + * @param list|null $enum + * @return non-empty-list + */ + private function reviewEnum( + array $types, + array|null $enum, + ): array|null { + if ($enum === null) { + return null; + } + + if ($enum === []) { + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any values', + ); + } + + // TODO validate enum against more than just type. + if ($types === []) { // currently only type is checked against enum values + return array_values($enum); // so if it is empty, return + } + + $enumContainsValidValue = false; + foreach ($enum as $value) { + if (in_array($value->getType(), $types)) { + $enumContainsValidValue = true; + } else { + $this->addWarning( + "$value does not match allowed types", + Warning::MISLEADING, + ); + } + } + + if (! $enumContainsValidValue) { + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any values that pass the schema', + ); + } + + return array_values($enum); + } + + /** + * @param list $types + */ + private function validateDefault(array $types, Value|null $default): Value|null + { + if ($default === null) { + return null; + } + + if (! in_array($default->getType(), $types)) { + throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); + } + + return $default; + } + + private function validateMultipleOf(float|int|null $value): float|int|null + { + if ($value === null || $value > 0) { + return $value; + } + + if ($value < 0) { + $this->addWarning( + 'multipleOf must be greater than zero', + Warning::INVALID, + ); + return abs($value); + } + + throw InvalidOpenAPI::keywordCannotBeZero( + $this->getIdentifier(), + 'multipleOf' + ); + } + + private function validateMinMax( + string $keyword, + float|int|null $minMax, + string $exclusiveKeyword, + bool|float|int|null $exclusiveMinMax, + ): Limit|null { + if (is_float($exclusiveMinMax) || is_integer($exclusiveMinMax)) { + throw InvalidOpenAPI::numericExclusiveMinMaxIn30( + $this->getIdentifier(), + $exclusiveKeyword, + ); + } + + if ($minMax === null) { + if ($exclusiveMinMax === true) { + $this->addWarning( + "$exclusiveKeyword has no effect without $keyword", + Warning::REDUNDANT, + ); + } + return null; + } + + return new Limit($minMax, $exclusiveMinMax ?? false); + } + + /** @return ($defaultsToZero is true ? int : int|null) */ + private function validateNonNegativeInteger( + string $keyword, + int|null $value, + bool $defaultsToZero, + ): int|null { + if ($value !== null && $value < 0) { + if (! $defaultsToZero) { + throw InvalidOpenAPI::keywordMustBeNonNegativeInteger( + $this->getIdentifier(), + $keyword + ); + } else { + $this->addWarning("$keyword must not be negative", Warning::INVALID); + return 0; + } + } + + return $value; + } + + /** @param list $types */ + private function reviewItems( + array $types, + Partial\Schema|null $items + ): Schema { + if (in_array(Type::Array, $types) && ! isset($items)) { + $this->addWarning( + 'items must be specified, if type is array', + Warning::INVALID, + ); + } + + return new Schema( + $this->getIdentifier()->append('items'), + $items ?? true, + ); + } + + /** + * @param array|null $required + * @return list + */ + private function validateRequired(array | null $required): array + { + if ($required === null) { + return []; + } + + if ($required === []) { + $this->addWarning('required must not be empty', Warning::INVALID); + return []; + } + + $uniqueRequired = array_unique($required); + + if (count($required) !== count($uniqueRequired)) { + $this->addWarning('required must not contain duplicates', Warning::INVALID); + } + + return $uniqueRequired; + } + + /** + * @param null|array $subSchemas + * @return list + */ + private function validateSubSchemas(string $keyword, ?array $subSchemas): array + { + if ($subSchemas === null) { + return []; + } + + if ($subSchemas === []) { + $this->addWarning("$keyword must not be empty", Warning::INVALID); + return []; + } + + $result = []; + foreach ($subSchemas as $index => $subSchema) { + $identifier = $this->appendedIdentifier($keyword, sprintf( + empty(trim($subSchema->title ?? '')) ? '%s' : '%2$s[%1$s]', + $index, + trim($subSchema->title ?? ''), + )); + $result[] = new Schema($identifier, $subSchema); + } + + return $result; + } + + /** + * @param array $properties + * @return array + */ + private function validateProperties(?array $properties): array + { + $properties ??= []; + + $result = []; + foreach ($properties as $key => $subSchema) { + if (!is_string($key)) { + throw InvalidOpenAPI::mustHaveStringKeys( + $this->getIdentifier(), + 'properties', + ); + } + + $result[$key] = new Schema( + $this->getIdentifier()->append("properties($key)"), + $subSchema + ); + } + + return $result; + } + + public function formatMetadataString(string|null $metadata): string + { + return trim($metadata ?? ''); + } +} diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 0df8119..f6a2b38 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -4,138 +4,55 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\V30; -use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; -use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; +use Membrane\OpenAPIReader\ValueObject\Valid\Exception\SchemaShouldBeBoolean; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; -use Membrane\OpenAPIReader\ValueObject\Valid\Warning; -use Membrane\OpenAPIReader\ValueObject\Value; final class Schema extends Validated implements Valid\Schema { - public readonly Type|null $type; - public readonly bool $nullable; - /** @var array|null */ - public readonly array|null $enum; - public readonly Value|null $default; - - public readonly float|int|null $multipleOf; - public readonly float|int|null $maximum; - public readonly bool $exclusiveMaximum; - public readonly float|int|null $minimum; - public readonly bool $exclusiveMinimum; - - public readonly int|null $maxLength; - public readonly int $minLength; - public readonly string|null $pattern; - - /** @var Schema|null */ - public readonly Schema|null $items; - public readonly int|null $maxItems; - public readonly int $minItems; - public readonly bool $uniqueItems; - - public readonly int|null $maxProperties; - public readonly int $minProperties; - /** @var non-empty-array|null */ - public readonly array|null $required; - /** @var array */ - public readonly array $properties; - public readonly bool|Schema $additionalProperties; - - /** @var non-empty-array|null */ - public readonly array|null $allOf; - /** @var non-empty-array|null */ - public readonly ?array $anyOf; - /** @var non-empty-array|null */ - public readonly array|null $oneOf; - public readonly Schema|null $not; - - public readonly string|null $format; - - public readonly string|null $title; - public readonly string|null $description; - - /** @var Type[] */ - private readonly array $typesItCanBe; + public readonly bool|Keywords $value; public function __construct( Identifier $identifier, - Partial\Schema $schema + bool|Partial\Schema $schema, ) { parent::__construct($identifier); - $this->type = $this->validateType($this->getIdentifier(), $schema->type); - $this->nullable = $schema->nullable; - $this->enum = $schema->enum; - $this->default = $schema->default; - - $this->multipleOf = $this->validatePositiveNumber('multipleOf', $schema->multipleOf); - $this->maximum = $schema->maximum; - $this->exclusiveMaximum = $this->validateExclusiveMinMax('exclusiveMaximum', $schema->exclusiveMaximum); - $this->minimum = $schema->minimum; - $this->exclusiveMinimum = $this->validateExclusiveMinMax('exclusiveMinimum', $schema->exclusiveMinimum); - - $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength); - $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength) ?? 0; - $this->pattern = $schema->pattern; - - $this->items = $this->validateItems($this->type, $schema->items); - $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems); - $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems) ?? 0; - $this->uniqueItems = $schema->uniqueItems; - - $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties); - $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties) ?? 0; - $this->required = $this->validateRequired($schema->required); - $this->properties = $this->validateProperties($schema->properties); - $this->additionalProperties = isset($schema->additionalProperties) ? (is_bool($schema->additionalProperties) ? - $schema->additionalProperties : - new Schema($this->getIdentifier()->append('additionalProperties'), $schema->additionalProperties)) : - true; - - $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); - $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); - $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); - $this->not = isset($schema->not) ? - new Schema($this->getIdentifier()->append('not'), $schema->not) : - null; - - $this->format = $schema->format; - - $this->title = $schema->title; - $this->description = $schema->description; - - $this->typesItCanBe = array_map( - fn($t) => Type::from($t), - $this->typesItCanBe() - ); - - if (empty($this->typesItCanBe)) { - $this->addWarning( - 'no data type can satisfy this schema', - Warning::IMPOSSIBLE_SCHEMA - ); + if (is_bool($schema)) { + $this->value = $schema; + } else { + try { + $this->value = new Keywords( + $this->getIdentifier(), + $this->getWarnings(), + $schema + ); + } catch (SchemaShouldBeBoolean $e) { + $this->addWarning($e->getMessage(), Valid\Warning::IMPOSSIBLE_SCHEMA); + $this->value = $e->getCode() === SchemaShouldBeBoolean::ALWAYS_TRUE; + } } } public function canBe(Type $type): bool { - return in_array($type, $this->typesItCanBe); + return in_array($type->value, $this->typesItCanBe()); } public function canOnlyBe(Type $type): bool { - return [$type] === $this->typesItCanBe; + return [$type->value] === $this->typesItCanBe(); } public function canBePrimitive(): bool { - foreach ($this->typesItCanBe as $typeItCanBe) { + $types = array_map(fn($t) => Type::from($t), $this->typesItCanBe()); + + foreach ($types as $typeItCanBe) { if ($typeItCanBe->isPrimitive()) { return true; } @@ -144,200 +61,18 @@ public function canBePrimitive(): bool return false; } - /** @return Type[] */ - public function getTypes(): array - { - $result = isset($this->type) ? - [$this->type] : - Type::casesForVersion(OpenAPIVersion::Version_3_0); - - if ($this->nullable) { - $result[] = Type::Null; - } - - return $result; - } - - public function getRelevantMaximum(): ?Limit - { - return isset($this->maximum) ? - new Limit($this->maximum, $this->exclusiveMaximum) : - null; - } - - public function getRelevantMinimum(): ?Limit - { - return isset($this->minimum) ? - new Limit($this->minimum, $this->exclusiveMinimum) : - null; - } - - /** @return string[] */ - private function typesItCanBe(): array - { - $possibilities = [Type::valuesForVersion(OpenAPIVersion::Version_3_0)]; - - if ($this->type !== null) { - $possibilities[] = [$this->type->value]; - } - - if (!empty($this->allOf)) { - $possibilities[] = array_intersect(...array_map( - fn($s) => $s->typesItCanBe(), - $this->allOf, - )); - } - - if (!empty($this->anyOf)) { - $possibilities[] = array_unique(array_merge(...array_map( - fn($s) => $s->typesItCanBe(), - $this->anyOf - ))); - } - - if (!empty($this->oneOf)) { - $possibilities[] = array_unique(array_merge(...array_map( - fn($s) => $s->typesItCanBe(), - $this->oneOf - ))); - } - - return array_values(array_intersect(...$possibilities)); - } - - /** @param null|string|array $type */ - private function validateType(Identifier $identifier, null|string|array $type): ?Type - { - if (is_null($type)) { - return null; - } - - if (is_array($type)) { - throw InvalidOpenAPI::typeArrayInWrongVersion($identifier); - } - - return Type::tryFromVersion( - OpenAPIVersion::Version_3_0, - $type - ) ?? throw InvalidOpenAPI::invalidType($identifier, $type); - } - - private function validateExclusiveMinMax( - string $keyword, - bool|float|int|null $exclusiveMinMax, - ): bool { - if (is_float($exclusiveMinMax) || is_integer($exclusiveMinMax)) { - throw InvalidOpenAPI::numericExclusiveMinMaxIn30($this->getIdentifier(), $keyword); - } - - return $exclusiveMinMax ?? false; - } - - private function validatePositiveNumber( - string $keyword, - float|int|null $value - ): float|int|null { - if ($value !== null && $value <= 0) { - throw InvalidOpenAPI::keywordMustBeStrictlyPositiveNumber($this->getIdentifier(), $keyword); - } - - return $value; - } - - private function validateNonNegativeInteger( - string $keyword, - int|null $value - ): int|null { - if ($value !== null && $value < 0) { - throw InvalidOpenAPI::keywordMustBeNegativeInteger($this->getIdentifier(), $keyword); - } - - return $value; - } - - /** - * @param array|null $value - * @return non-empty-array|null - */ - private function validateRequired(array|null $value): array|null + /** @return list */ + public function typesItCanBe(): array { - if ($value === null) { - return $value; - } - - if ($value === []) { - throw InvalidOpenAPI::mustBeNonEmpty($this->getIdentifier(), 'required'); - } - - if (count($value) !== count(array_unique($value))) { - throw InvalidOpenAPI::mustContainUniqueItems($this->getIdentifier(), 'required'); + if ($this->value === true) { + return [ + Type::Null->value, + ...Type::valuesForVersion(OpenAPIVersion::Version_3_0), + ]; + } elseif ($this->value === false) { + return []; + } else { + return $this->value->typesItCanBe(); } - - return $value; - } - - /** - * @param null|array $subSchemas - * @return null|non-empty-array - */ - private function validateSubSchemas(string $keyword, ?array $subSchemas): ?array - { - if ($subSchemas === null) { - return null; - } - - if ($subSchemas === []) { - throw InvalidOpenAPI::mustBeNonEmpty($this->getIdentifier(), $keyword); - } - - $result = []; - foreach ($subSchemas as $index => $subSchema) { - $result[] = new Schema( - $this->getIdentifier()->append("$keyword($index)"), - $subSchema - ); - } - - return $result; - } - - /** - * @param array $properties - * @return array - */ - private function validateProperties(?array $properties): array - { - $properties ??= []; - - $result = []; - foreach ($properties as $key => $subSchema) { - if (!is_string($key)) { - throw InvalidOpenAPI::mustHaveStringKeys( - $this->getIdentifier(), - 'properties', - ); - } - - $result[$key] = new Schema( - $this->getIdentifier()->append("properties($key)"), - $subSchema - ); - } - - return $result; - } - - private function validateItems(Type|null $type, Partial\Schema|null $items): Schema|null - { - if (is_null($items)) { - //@todo update tests to support this validation - //if ($type == Type::Array) { - // throw InvalidOpenAPI::mustSpecifyItemsForArrayType($this->getIdentifier()); - //} - - return $items; - } - - return new Schema($this->getIdentifier()->append('items'), $items); } } diff --git a/src/ValueObject/Valid/V31/Keywords.php b/src/ValueObject/Valid/V31/Keywords.php new file mode 100644 index 0000000..d00be9c --- /dev/null +++ b/src/ValueObject/Valid/V31/Keywords.php @@ -0,0 +1,466 @@ + */ + public readonly array $types; + + /** @var non-empty-list|null */ + public readonly array|null $enum; + public readonly Value|null $default; + + public readonly float|int|null $multipleOf; + public readonly Limit|null $maximum; + public readonly Limit|null $minimum; + + public readonly int|null $maxLength; + public readonly int $minLength; + public readonly string|null $pattern; + + public readonly Schema $items; + public readonly int|null $maxItems; + public readonly int $minItems; + public readonly bool $uniqueItems; + + public readonly int|null $maxProperties; + public readonly int $minProperties; + /** @var list */ + public readonly array $required; + /** @var array */ + public readonly array $properties; + public readonly bool|Schema $additionalProperties; + + /** @var list */ + public readonly array $allOf; + /** @var list */ + public readonly array $anyOf; + /** @var list */ + public readonly array $oneOf; + public readonly Schema $not; + + public readonly string $format; + + public readonly string $title; + public readonly string $description; + + /** @var Type[] */ + private readonly array $typesItCanBe; + + public function __construct( + Identifier $identifier, + Valid\Warnings $warnings, + Partial\Schema $schema + ) { + parent::__construct($identifier, $warnings); + + $this->types = $this->validateTypes($schema->type); + $this->enum = $this->reviewEnum($this->types, $schema->const, $schema->enum); + $this->default = $this->validateDefault($this->types, $schema->default); + + $this->multipleOf = $this->validateMultipleOf($schema->multipleOf); + $this->maximum = $this->validateMaximum($schema->maximum, $schema->exclusiveMaximum); + $this->minimum = $this->validateMinimum($schema->minimum, $schema->exclusiveMinimum); + + $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength, false); + $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength, true); + $this->pattern = $schema->pattern; //TODO validatePattern is valid regex + + $this->items = $this->reviewItems($this->types, $schema->items); + $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems, false); + $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems, true); + $this->uniqueItems = $schema->uniqueItems; + + $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties, false); + $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties, true); + //TODO if a property is defined as a false schema AND required, that should be a warning + //TODO if property is false schema and required, and type must be object, the whole schema is the false schema + $this->required = $this->validateRequired($schema->required); + $this->properties = $this->validateProperties($schema->properties); + $this->additionalProperties = new Schema( + $this->appendedIdentifier('additionalProperties'), + $schema->additionalProperties, + ); + + + //TODO throw ShouldBeBooleanSchema::false if allOf contains false schema + $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); + $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); + $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); + $this->not = new Schema( + $this->appendedIdentifier('not'), + $schema->not ?? false, + ); + + $this->format = $this->formatMetadataString($schema->format); + + $this->title = $this->formatMetadataString($schema->title); + $this->description = $this->formatMetadataString($schema->description); + + $this->typesItCanBe = array_map( + fn($t) => Type::from($t), + $this->typesItCanBe() + ); + + if (empty($this->typesItCanBe)) { + $this->addWarning( + 'no data type can satisfy this schema', + Warning::IMPOSSIBLE_SCHEMA + ); + } + } + + /** @return string[] */ + public function typesItCanBe(): array + { + $possibilities = [array_merge( + Type::valuesForVersion(OpenAPIVersion::Version_3_0), + [Type::Null->value], + )]; + + if ($this->types !== []) { + $possibilities[] = array_map(fn($t) => $t->value, $this->types); + } + + if (!empty($this->allOf)) { + $possibilities[] = array_intersect(...array_map( + fn($s) => $s->typesItCanBe(), + $this->allOf, + )); + } + + if (!empty($this->anyOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->anyOf + ))); + } + + if (!empty($this->oneOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->oneOf + ))); + } + + return array_values(array_intersect(...$possibilities)); + } + + /** + * @param null|string|array $type + * @return list + */ + private function validateTypes(null|string|array $type): array + { + if (empty($type)) { + return []; + } + + if (is_string($type)) { + $type = [$type]; + } + + return array_map( + fn($t) => Type::tryFromVersion(OpenAPIVersion::Version_3_1, $t) + ?? throw InvalidOpenAPI::invalidType($this->getIdentifier(), $t), + $type, + ); + } + + /** + * @param list $types + * @param list|null $enum + * @return non-empty-list|null + */ + private function reviewEnum( + array $types, + Value|null $const, + array|null $enum, + ): array|null { + if ($const !== null) { + if (empty($enum)) { + return [$const]; + } elseif (in_array($const, $enum)) { + $this->addWarning( + 'enum is redundant when const is specified', + Warning::REDUNDANT, + ); + return [$const]; + } else { + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'const is not contained within enum, ' + . 'one or the other will always fail', + ); + } + } + + if ($enum === null) { + return null; + } + + if ($enum === []) { + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any values', + ); + } + + if ($types === []) { + return array_values($enum); + } + + $enumContainsValidValue = false; + foreach ($enum as $value) { + if (in_array($value->getType(), $types)) { + $enumContainsValidValue = true; + } else { + $this->addWarning( + "$value does not match allowed types", + Warning::MISLEADING, + ); + } + } + + if (! $enumContainsValidValue) { + throw Valid\Exception\SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any valid values', + ); + } + + return array_values($enum); + } + + /** + * @param list $types + */ + private function validateDefault(array $types, Value|null $default): Value|null + { + if ($default === null) { + return null; + } + + if (! in_array($default->getType(), $types)) { + throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); + } + + return $default; + } + + private function validateMultipleOf(float|int|null $value): float|int|null + { + if ($value === null || $value > 0) { + return $value; + } + + if ($value < 0) { + $this->addWarning( + 'multipleOf must be greater than zero', + Warning::INVALID, + ); + return abs($value); + } + + throw InvalidOpenAPI::keywordCannotBeZero( + $this->getIdentifier(), + 'multipleOf' + ); + } + + private function validateMinimum( + float|int|null $minimum, + bool|float|int|null $exclusiveMinimum, + ): Limit|null { + if (is_bool($exclusiveMinimum)) { + throw InvalidOpenAPI::boolExclusiveMinMaxIn31( + $this->getIdentifier(), + 'exclusiveMinimum', + ); + } + + if (isset($exclusiveMinimum) && isset($minimum)) { + $this->addWarning( + 'Having both minimum and exclusiveMinimum is redundant, ' + . 'only the stricter one will ever apply', + Warning::REDUNDANT, + ); + return $exclusiveMinimum >= $minimum ? + new Limit($exclusiveMinimum, true) : + new Limit($minimum, false); + } elseif (isset($exclusiveMinimum)) { + return new Limit($exclusiveMinimum, true); + } elseif (isset($minimum)) { + return new Limit($minimum, false); + } else { + return null; + } + } + + private function validateMaximum( + float|int|null $maximum, + bool|float|int|null $exclusiveMaximum, + ): Limit|null { + if (is_bool($exclusiveMaximum)) { + throw InvalidOpenAPI::boolExclusiveMinMaxIn31( + $this->getIdentifier(), + 'exclusiveMaximum', + ); + } + + if (isset($exclusiveMaximum) && isset($maximum)) { + $this->addWarning( + 'Having both maximum and exclusiveMaximum is redundant, ' + . 'only the stricter one will ever apply', + Warning::REDUNDANT, + ); + return $exclusiveMaximum <= $maximum ? + new Limit($exclusiveMaximum, true) : + new Limit($maximum, false); + } elseif (isset($exclusiveMaximum)) { + return new Limit($exclusiveMaximum, true); + } elseif (isset($maximum)) { + return new Limit($maximum, false); + } else { + return null; + } + } + + /** @return ($defaultsToZero is true ? int : int|null) */ + private function validateNonNegativeInteger( + string $keyword, + int|null $value, + bool $defaultsToZero, + ): int|null { + if ($value !== null && $value < 0) { + if (! $defaultsToZero) { + throw InvalidOpenAPI::keywordMustBeNonNegativeInteger( + $this->getIdentifier(), + $keyword + ); + } else { + $this->addWarning("$keyword must not be negative", Warning::INVALID); + return 0; + } + } + + return $value; + } + + /** @param list $types */ + private function reviewItems( + array $types, + Partial\Schema|null $items + ): Schema { + if (in_array(Type::Array, $types) && ! isset($items)) { + $this->addWarning( + 'items must be specified, if type is array', + Warning::INVALID, + ); + } + + return new Schema( + $this->getIdentifier()->append('items'), + $items ?? true, + ); + } + + /** + * @param array|null $required + * @return list + */ + private function validateRequired(array | null $required): array + { + if ($required === null) { + return []; + } + + if ($required === []) { + $this->addWarning('required must not be empty', Warning::INVALID); + return []; + } + + $uniqueRequired = array_unique($required); + + if (count($required) !== count($uniqueRequired)) { + $this->addWarning('required must not contain duplicates', Warning::INVALID); + } + + return $uniqueRequired; + } + + /** + * @param null|array $subSchemas + * @return list + */ + private function validateSubSchemas(string $keyword, ?array $subSchemas): array + { + if ($subSchemas === null) { + return []; + } + + if ($subSchemas === []) { + $this->addWarning("$keyword must not be empty", Warning::INVALID); + return []; + } + + $result = []; + foreach ($subSchemas as $index => $subSchema) { + $identifier = $this->appendedIdentifier($keyword, sprintf( + empty(trim($subSchema->title ?? '')) ? '%s' : '%2$s[%1$s]', + $index, + trim($subSchema->title ?? ''), + )); + $result[] = new Schema($identifier, $subSchema); + } + + return $result; + } + + /** + * @param array $properties + * @return array + */ + private function validateProperties(?array $properties): array + { + $properties ??= []; + + $result = []; + foreach ($properties as $key => $subSchema) { + if (!is_string($key)) { + throw InvalidOpenAPI::mustHaveStringKeys( + $this->getIdentifier(), + 'properties', + ); + } + + $result[$key] = new Schema( + $this->getIdentifier()->append("properties($key)"), + $subSchema + ); + } + + return $result; + } + + public function formatMetadataString(string|null $metadata): string + { + return trim($metadata ?? ''); + } +} diff --git a/src/ValueObject/Valid/V31/Schema.php b/src/ValueObject/Valid/V31/Schema.php new file mode 100644 index 0000000..ccc41ab --- /dev/null +++ b/src/ValueObject/Valid/V31/Schema.php @@ -0,0 +1,78 @@ +value = $schema; + } else { + try { + $this->value = new Keywords( + $this->getIdentifier(), + $this->getWarnings(), + $schema + ); + } catch (SchemaShouldBeBoolean $e) { + $this->addWarning($e->getMessage(), Valid\Warning::IMPOSSIBLE_SCHEMA); + $this->value = $e->getCode() === SchemaShouldBeBoolean::ALWAYS_TRUE; + } + } + } + + public function canBe(Type $type): bool + { + return in_array($type->value, $this->typesItCanBe()); + } + + public function canOnlyBe(Type $type): bool + { + return [$type->value] === $this->typesItCanBe(); + } + + public function canBePrimitive(): bool + { + $types = array_map(fn($t) => Type::from($t), $this->typesItCanBe()); + + foreach ($types as $typeItCanBe) { + if ($typeItCanBe->isPrimitive()) { + return true; + } + } + + return false; + } + + /** @return list */ + public function typesItCanBe(): array + { + if ($this->value === true) { + return [ + Type::Null->value, + ...Type::valuesForVersion(OpenAPIVersion::Version_3_0), + ]; + } elseif ($this->value === false) { + return []; + } else { + return $this->value->typesItCanBe(); + } + } +} diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php index ec1e0a6..b866644 100644 --- a/src/ValueObject/Valid/Validated.php +++ b/src/ValueObject/Valid/Validated.php @@ -4,14 +4,20 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid; +/** + * A Validated object **may** make _opinionated simplifications_ to improve DX. + * - It **may** change _appearance_ from its OpenAPI counterpart. + * - It **must** express the same _intent_ as its OpenAPI counterpart. + */ abstract class Validated implements HasIdentifier, HasWarnings { private Warnings $warnings; public function __construct( private readonly Identifier $identifier, + Warnings|null $warnings = null, ) { - $this->warnings = new Warnings($this->identifier); + $this->warnings = $warnings ?? new Warnings($this->identifier); } public function getIdentifier(): Identifier @@ -32,6 +38,7 @@ public function hasWarnings(): bool return $this->warnings->hasWarnings(); } + /** @return Warnings contains the list of issues found during validation */ public function getWarnings(): Warnings { return $this->warnings; diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 21339fc..eb00fd7 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -12,6 +12,12 @@ public function __construct( ) { } + public const INVALID = 'invalid'; + + public const MISLEADING = 'misleading'; + + public const REDUNDANT = 'redundant'; + /** * Server Variable: "enum" SHOULD NOT be empty * Schema: "enum" SHOULD have at least one element diff --git a/src/ValueObject/Value.php b/src/ValueObject/Value.php index 9f6a6ea..ad0ac69 100644 --- a/src/ValueObject/Value.php +++ b/src/ValueObject/Value.php @@ -4,10 +4,45 @@ namespace Membrane\OpenAPIReader\ValueObject; -final class Value +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; +use RuntimeException; +use Stringable; + +final class Value implements Stringable { public function __construct( public readonly mixed $value, ) { } + + public function getType(): Type + { + if (is_array($this->value)) { + return array_is_list($this->value) ? Type::Array : Type::Object; + } + + if (is_bool($this->value)) { + return Type::Boolean; + } + + if (is_float($this->value)) { + return Type::Number; + } + + if (is_int($this->value)) { + return Type::Integer; + } + + if (is_string($this->value)) { + return Type::String; + } + + return Type::Null; + } + + public function __toString(): string + { + return json_encode($this->value) ?: + throw new RuntimeException('Failed to encode value'); + } } diff --git a/tests/ValueObject/Valid/V30/KeywordsTest.php b/tests/ValueObject/Valid/V30/KeywordsTest.php new file mode 100644 index 0000000..479d104 --- /dev/null +++ b/tests/ValueObject/Valid/V30/KeywordsTest.php @@ -0,0 +1,77 @@ +getWarnings()->all()); + } + + #[Test] + #[TestDox('It simplifies schema keywords where possible')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV30')] + public function itSimplifiesKeywords( + Partial\Schema $schema, + string $propertyName, + mixed $expected, + ): void { + $identifier = new Identifier('sut'); + $sut = new Keywords($identifier, new Warnings($identifier), $schema); + + self::assertEquals($expected, $sut->{$propertyName}); + } + + #[Test] + #[TestDox('It invalidates schema keywords for non-recoverable issues')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV30')] + public function itInvalidatesKeywords( + InvalidOpenAPI $expected, + Identifier $identifier, + Partial\Schema $schema, + ): void { + $warnings = new Warnings($identifier); + + self::expectExceptionObject($expected); + + new Keywords($identifier, $warnings, $schema); + } +} diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index b3184d9..4e25e3c 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -7,7 +7,8 @@ use Generator; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; -use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; +use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesReviewedSchemas; +use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesSimplifiedSchemas; use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; @@ -16,8 +17,10 @@ use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; +use Membrane\OpenAPIReader\ValueObject\Value; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\UsesClass; @@ -33,9 +36,30 @@ #[UsesClass(Warnings::class)] class SchemaTest extends TestCase { + #[Test] + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'forV3X')] + public function itReviewsSchema(Partial\Schema $schema, Warning $warning): void + { + $sut = new Schema(new Identifier('sut'), $schema); + + self::assertContainsEquals($warning, $sut->getWarnings()->all()); + } + + #[Test] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] + public function itSimplifiesSchema( + Partial\Schema $schema, + string $propertyName, + mixed $expected, + ): void { + $sut = new Schema(new Identifier('sut'), $schema); + + self::assertEquals($expected, $sut->value->{$propertyName}); + } + #[Test] #[DataProvider('provideInvalidSchemas')] - public function itThrowsOnInvalidSchemas( + public function itValidatesSchema( InvalidOpenAPI $expected, Identifier $identifier, Partial\Schema $partialSchema, @@ -114,7 +138,7 @@ public function itGetsRelevantMaximum(?Limit $expected, Partial\Schema $schema): { $sut = new Schema(new Identifier(''), $schema); - self::assertEquals($expected, $sut->getRelevantMaximum()); + self::assertEquals($expected, $sut->value->maximum); } #[Test] @@ -124,28 +148,24 @@ public function itGetsRelevantMinimum(?Limit $expected, Partial\Schema $schema): { $sut = new Schema(new Identifier(''), $schema); - self::assertEquals($expected, $sut->getRelevantMinimum()); + self::assertEquals($expected, $sut->value->minimum); } /** * @param Type[] $expected */ #[Test] - #[TestDox('It gets the types allowed, in a version agnostic format')] + #[TestDox('It gets types specified, in a version agnostic format')] #[DataProvider('provideSchemasToGetTypes')] public function itGetsTypes(array $expected, Partial\Schema $schema): void { $sut = new Schema(new Identifier(''), $schema); - self::assertEqualsCanonicalizing($expected, $sut->getTypes()); + self::assertEqualsCanonicalizing($expected, $sut->value->types); } public static function provideInvalidSchemas(): Generator { - foreach (self::provideInvalidComplexSchemas() as $case => $dataset) { - yield $case => $dataset; - } - yield 'invalid type' => [ InvalidOpenAPI::invalidType(new Identifier('invalid type'), 'invalid'), new Identifier('invalid type'), @@ -160,40 +180,68 @@ public static function provideInvalidSchemas(): Generator new Identifier('properties list'), new Partial\Schema(properties: [new Partial\Schema()]), ]; - } - private static function provideInvalidComplexSchemas(): Generator - { - $xOfs = [ - 'allOf' => fn(Partial\Schema ...$subSchemas) => PartialHelper::createSchema( - allOf: $subSchemas + yield 'negative maxLength' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxLength'), + 'maxLength', ), - 'anyOf' => fn(Partial\Schema ...$subSchemas) => PartialHelper::createSchema( - anyOf: $subSchemas + new Identifier('negative maxLength'), + new Partial\Schema(maxLength: -1), + ]; + + yield 'negative maxItems' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxItems'), + 'maxItems', ), - 'oneOf' => fn(Partial\Schema ...$subSchemas) => PartialHelper::createSchema( - oneOf: $subSchemas + new Identifier('negative maxItems'), + new Partial\Schema(maxItems: -1), + ]; + + yield 'negative maxProperties' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxProperties'), + 'maxProperties', + ), + new Identifier('negative maxProperties'), + new Partial\Schema(maxProperties: -1), + ]; + + yield 'zero multipleOf' => [ + InvalidOpenAPI::keywordCannotBeZero( + new Identifier('zero multipleOf'), + 'multipleOf', ), + new Identifier('zero multipleOf'), + new Partial\Schema(multipleOf: 0), ]; - $identifier = new Identifier('test-schema'); - $case = fn(Identifier $exceptionId, Partial\Schema $schema, string $keyword) => [ - InvalidOpenAPI::mustBeNonEmpty($exceptionId, $keyword), - $identifier, - $schema + yield 'default does not conform to type' => [ + InvalidOpenAPI::defaultMustConformToType( + new Identifier('non-conforming default'), + ), + new Identifier('non-conforming default'), + new Partial\Schema(type: 'string', default: new Value(1)), ]; - foreach ($xOfs as $keyword => $xOf) { - yield "empty $keyword" => $case($identifier, $xOf(), $keyword); + yield 'numeric exclusiveMaximum in 3.0' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30( + new Identifier('numeric exclusiveMaximum'), + 'exclusiveMaximum' + ), + new Identifier('numeric exclusiveMaximum'), + new Partial\Schema(exclusiveMaximum: 5), + ]; - foreach ($xOfs as $otherKeyWord => $otherXOf) { - yield "$keyword with empty $otherKeyWord inside" => $case( - $identifier->append($keyword, '0'), - $xOf($otherXOf()), - $otherKeyWord, - ); - } - } + yield 'numeric exclusiveMinimum in 3.0' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30( + new Identifier('numeric exclusiveMinimum'), + 'exclusiveMinimum' + ), + new Identifier('numeric exclusiveMinimum'), + new Partial\Schema(exclusiveMinimum: 5), + ]; } /** @@ -207,53 +255,53 @@ public static function provideSchemasToCheckTypes(): Generator { foreach (Type::cases() as $typeToCheck) { yield "$typeToCheck->value? empty schema" => [ - Type::casesForVersion(OpenAPIVersion::Version_3_0), - $typeToCheck, - PartialHelper::createSchema(), + Type::cases(), + $typeToCheck, + new Partial\Schema(), ]; foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { yield "$typeToCheck->value? top level type: $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(type: $type->value) + new Partial\Schema(type: $type->value) ]; yield "$typeToCheck->value? allOf MUST be $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(allOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(allOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ]) ]; yield "$typeToCheck->value? anyOf MUST be $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(anyOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ]) ]; yield "$typeToCheck->value? oneOf MUST be $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ]) ]; yield "$typeToCheck->value? top-level type: string, allOf MUST be $type->value" => [ $type === Type::String ? [Type::String] : [], $typeToCheck, - PartialHelper::createSchema( + new Partial\Schema( type: Type::String->value, allOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ] ) ]; @@ -261,11 +309,11 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? top-level type: number, anyOf MUST be $type->value" => [ $type === Type::Number ? [Type::Number] : [], $typeToCheck, - PartialHelper::createSchema( + new Partial\Schema( type: Type::Number->value, anyOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ] ) ]; @@ -273,11 +321,11 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? top-level type: array, oneOf MUST be $type->value" => [ $type === Type::Array ? [Type::Array] : [], $typeToCheck, - PartialHelper::createSchema( + new Partial\Schema( type: Type::Array->value, oneOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ] ) ]; @@ -287,9 +335,9 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? anyOf MAY be $type->value|string" => [ [$type, Type::String], $typeToCheck, - PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: 'string'), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(anyOf: [ + new Partial\Schema(type: 'string'), + new Partial\Schema(type: $type->value), ]) ]; } @@ -298,9 +346,9 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? oneOf MAY be $type->value|boolean" => [ [$type, Type::Boolean], $typeToCheck, - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: 'boolean'), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(oneOf: [ + new Partial\Schema(type: 'boolean'), + new Partial\Schema(type: $type->value), ]) ]; } @@ -309,10 +357,10 @@ public static function provideSchemasToCheckTypes(): Generator yield "can it be $typeToCheck->value? allOf contains oneOf that may be $type->value|integer" => [ [$type, Type::Integer], $typeToCheck, - PartialHelper::createSchema(allOf: [ - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: 'integer') + new Partial\Schema(allOf: [ + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: 'integer') ]), ]) ]; @@ -347,7 +395,7 @@ public static function provideSchemasWithMax(): Generator { yield 'no min or max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -357,7 +405,7 @@ public static function provideSchemasWithMax(): Generator yield 'inclusive min' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -367,7 +415,7 @@ public static function provideSchemasWithMax(): Generator yield 'exclusive min' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: null, @@ -377,7 +425,7 @@ public static function provideSchemasWithMax(): Generator yield 'inclusive max' => [ new Limit(1, false), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: 1, @@ -387,7 +435,7 @@ public static function provideSchemasWithMax(): Generator yield 'exclusive max' => [ new Limit(1, true), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: true, exclusiveMinimum: false, maximum: 1, @@ -397,7 +445,7 @@ public static function provideSchemasWithMax(): Generator yield 'inclusive max and exclusive min' => [ new Limit(5, false), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: 5, @@ -416,7 +464,7 @@ public static function provideSchemasWithMin(): Generator { yield 'no min or max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -426,7 +474,7 @@ public static function provideSchemasWithMin(): Generator yield 'inclusive min' => [ new Limit(1, false), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -436,7 +484,7 @@ public static function provideSchemasWithMin(): Generator yield 'exclusive min' => [ new Limit(1, true), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: null, @@ -446,7 +494,7 @@ public static function provideSchemasWithMin(): Generator yield 'inclusive max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: 1, @@ -456,7 +504,7 @@ public static function provideSchemasWithMin(): Generator yield 'exclusive max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: true, exclusiveMinimum: false, maximum: 1, @@ -466,7 +514,7 @@ public static function provideSchemasWithMin(): Generator yield 'inclusive max and exclusive min' => [ new Limit(1, true), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: 5, @@ -481,48 +529,48 @@ public static function provideSchemasWithMin(): Generator public static function provideSchemasToGetTypes(): Generator { yield 'no type' => [ - Type::casesForVersion(OpenAPIVersion::Version_3_0), - PartialHelper::createSchema(), + [], + new Partial\Schema(), ]; yield 'nullable' => [ - [Type::Null, ...Type::casesForVersion(OpenAPIVersion::Version_3_0)], - PartialHelper::createSchema(nullable: true) + [], + new Partial\Schema(nullable: true) ]; - yield 'string' => [[Type::String], PartialHelper::createSchema(type: 'string')]; - yield 'integer' => [[Type::Integer], PartialHelper::createSchema(type: 'integer')]; - yield 'number' => [[Type::Number], PartialHelper::createSchema(type: 'number')]; - yield 'boolean' => [[Type::Boolean], PartialHelper::createSchema(type: 'boolean')]; + yield 'string' => [[Type::String], new Partial\Schema(type: 'string')]; + yield 'integer' => [[Type::Integer], new Partial\Schema(type: 'integer')]; + yield 'number' => [[Type::Number], new Partial\Schema(type: 'number')]; + yield 'boolean' => [[Type::Boolean], new Partial\Schema(type: 'boolean')]; yield 'array' => [ [Type::Array], - PartialHelper::createSchema(type: 'array', items: PartialHelper::createSchema()), + new Partial\Schema(type: 'array', items: new Partial\Schema()), ]; - yield 'object' => [[Type::Object], PartialHelper::createSchema(type: 'object')]; + yield 'object' => [[Type::Object], new Partial\Schema(type: 'object')]; yield 'nullable string' => [ [Type::String, Type::Null], - PartialHelper::createSchema(type: 'string', nullable: true), + new Partial\Schema(type: 'string', nullable: true), ]; yield 'nullable integer' => [ [Type::Integer, Type::Null], - PartialHelper::createSchema(type: 'integer', nullable: true), + new Partial\Schema(type: 'integer', nullable: true), ]; yield 'nullable number' => [ [Type::Number, Type::Null], - PartialHelper::createSchema(type: 'number', nullable: true), + new Partial\Schema(type: 'number', nullable: true), ]; yield 'nullable boolean' => [ [Type::Boolean, Type::Null], - PartialHelper::createSchema(type: 'boolean', nullable: true), + new Partial\Schema(type: 'boolean', nullable: true), ]; yield 'nullable array' => [ [Type::Array, Type::Null], - PartialHelper::createSchema(type: 'array', nullable: true, items: PartialHelper::createSchema()), + new Partial\Schema(type: 'array', nullable: true, items: new Partial\Schema()), ]; yield 'nullable object' => [ [Type::Object, Type::Null], - PartialHelper::createSchema(type: 'object', nullable: true), + new Partial\Schema(type: 'object', nullable: true), ]; } } diff --git a/tests/ValueObject/Valid/V31/KeywordsTest.php b/tests/ValueObject/Valid/V31/KeywordsTest.php new file mode 100644 index 0000000..b7f353d --- /dev/null +++ b/tests/ValueObject/Valid/V31/KeywordsTest.php @@ -0,0 +1,77 @@ +getWarnings()->all()); + } + + #[Test] + #[TestDox('It simplifies schema keywords where possible')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV31')] + public function itSimplifiesKeywords( + Partial\Schema $schema, + string $propertyName, + mixed $expected, + ): void { + $identifier = new Identifier('sut'); + $sut = new Keywords($identifier, new Warnings($identifier), $schema); + + self::assertEquals($expected, $sut->{$propertyName}); + } + + #[Test] + #[TestDox('It invalidates schema keywords for non-recoverable issues')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV31')] + public function itInvalidatesKeywords( + InvalidOpenAPI $expected, + Identifier $identifier, + Partial\Schema $schema, + ): void { + $warnings = new Warnings($identifier); + + self::expectExceptionObject($expected); + + new Keywords($identifier, $warnings, $schema); + } +} diff --git a/tests/ValueObject/Valid/V31/SchemaTest.php b/tests/ValueObject/Valid/V31/SchemaTest.php new file mode 100644 index 0000000..2e0c145 --- /dev/null +++ b/tests/ValueObject/Valid/V31/SchemaTest.php @@ -0,0 +1,552 @@ +getWarnings()->all()); + } + + #[Test] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] + public function itSimplifiesSchema( + Partial\Schema $schema, + string $propertyName, + mixed $expected, + ): void { + $sut = new Schema(new Identifier('test'), $schema); + + self::assertEquals($expected, $sut->value->{$propertyName}); + } + + #[Test] + #[DataProvider('provideInvalidSchemas')] + public function itValidatesSchema( + InvalidOpenAPI $expected, + Identifier $identifier, + Partial\Schema $partialSchema, + ): void { + self::expectExceptionObject($expected); + + new Schema($identifier, $partialSchema); + } + + /** @param Type[] $typesItCanBe */ + #[Test, DataProvider('provideSchemasToCheckTypes')] + public function itKnowsIfItCanBeACertainType( + array $typesItCanBe, + Type $typeToCheck, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier(''), $partialSchema); + + self::assertSame( + in_array($typeToCheck, $typesItCanBe, true), + $sut->canBe($typeToCheck) + ); + } + + /** @param Type[] $typesItCanBe */ + #[Test, DataProvider('provideSchemasToCheckTypes')] + public function itKnowsIfItCanOnlyBeACertainType( + array $typesItCanBe, + Type $typeToCheck, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier(''), $partialSchema); + + self::assertSame( + [$typeToCheck] === $typesItCanBe, + $sut->canOnlyBe($typeToCheck) + ); + } + + /** @param Type[] $typesItCanBe */ + #[Test, DataProvider('provideSchemasToCheckTypes')] + public function itKnowsIfItCanOnlyBePrimitive( + array $typesItCanBe, + Type $typeToCheck, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier(''), $partialSchema); + + self::assertSame( + !empty(array_filter($typesItCanBe, fn($t) => !in_array( + $t, + [Type::Array, Type::Object] + ))), + $sut->canBePrimitive() + ); + } + + #[Test] + #[DataProvider('provideSchemasAcceptNoTypes')] + public function itWarnsAgainstImpossibleSchemas( + bool $expected, + Partial\Schema $schema, + ): void { + $sut = new Schema(new Identifier(''), $schema); + + self::assertSame( + $expected, + $sut->getWarnings()->hasWarningCode(Warning::IMPOSSIBLE_SCHEMA) + ); + } + + #[Test] + #[TestDox('It determines the relevant numeric inclusive|exclusive maximum, if there is one')] + #[DataProvider('provideSchemasWithMax')] + public function itGetsRelevantMaximum(?Limit $expected, Partial\Schema $schema): void + { + $sut = new Schema(new Identifier(''), $schema); + + self::assertEquals($expected, $sut->value->maximum); + } + + #[Test] + #[TestDox('It determines the relevant numeric inclusive|exclusive minimum, if there is one')] + #[DataProvider('provideSchemasWithMin')] + public function itGetsRelevantMinimum(?Limit $expected, Partial\Schema $schema): void + { + $sut = new Schema(new Identifier(''), $schema); + + self::assertEquals($expected, $sut->value->minimum); + } + + /** + * @param Type[] $expected + */ + #[Test] + #[TestDox('It gets types specified, in a version agnostic format')] + #[DataProvider('provideSchemasToGetTypes')] + public function itGetsTypes(array $expected, Partial\Schema $schema): void + { + $sut = new Schema(new Identifier(''), $schema); + + self::assertEqualsCanonicalizing($expected, $sut->value->types); + } + + public static function provideInvalidSchemas(): Generator + { + yield 'invalid type' => [ + InvalidOpenAPI::invalidType(new Identifier('invalid type'), 'invalid'), + new Identifier('invalid type'), + new Partial\Schema(type: 'invalid'), + ]; + + yield 'properties list' => [ + InvalidOpenAPI::mustHaveStringKeys( + new Identifier('properties list'), + 'properties', + ), + new Identifier('properties list'), + new Partial\Schema(properties: [new Partial\Schema()]), + ]; + + yield 'negative maxLength' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxLength'), + 'maxLength', + ), + new Identifier('negative maxLength'), + new Partial\Schema(maxLength: -1), + ]; + + yield 'negative maxItems' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxItems'), + 'maxItems', + ), + new Identifier('negative maxItems'), + new Partial\Schema(maxItems: -1), + ]; + + yield 'negative maxProperties' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxProperties'), + 'maxProperties', + ), + new Identifier('negative maxProperties'), + new Partial\Schema(maxProperties: -1), + ]; + + yield 'zero multipleOf' => [ + InvalidOpenAPI::keywordCannotBeZero( + new Identifier('zero multipleOf'), + 'multipleOf', + ), + new Identifier('zero multipleOf'), + new Partial\Schema(multipleOf: 0), + ]; + + yield 'default does not conform to type' => [ + InvalidOpenAPI::defaultMustConformToType( + new Identifier('non-conforming default'), + ), + new Identifier('non-conforming default'), + new Partial\Schema(type: 'string', default: new Value(1)), + ]; + + yield 'bool exclusiveMaximum in 3.0' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31( + new Identifier('bool exclusiveMaximum'), + 'exclusiveMaximum' + ), + new Identifier('bool exclusiveMaximum'), + new Partial\Schema(exclusiveMaximum: true), + ]; + + yield 'bool exclusiveMinimum in 3.0' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31( + new Identifier('bool exclusiveMinimum'), + 'exclusiveMinimum' + ), + new Identifier('bool exclusiveMinimum'), + new Partial\Schema(exclusiveMinimum: false), + ]; + } + + /** + * @return \Generator + */ + public static function provideSchemasToCheckTypes(): Generator + { + foreach (Type::cases() as $typeToCheck) { + yield "$typeToCheck->value? empty schema" => [ + Type::cases(), + $typeToCheck, + new Partial\Schema(), + ]; + + foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { + yield "$typeToCheck->value? top level type: $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(type: $type->value) + ]; + + yield "$typeToCheck->value? allOf MUST be $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(allOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ]) + ]; + + yield "$typeToCheck->value? anyOf MUST be $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(anyOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ]) + ]; + + yield "$typeToCheck->value? oneOf MUST be $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ]) + ]; + + yield "$typeToCheck->value? top-level type: string, allOf MUST be $type->value" => [ + $type === Type::String ? [Type::String] : [], + $typeToCheck, + new Partial\Schema( + type: Type::String->value, + allOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ] + ) + ]; + + yield "$typeToCheck->value? top-level type: number, anyOf MUST be $type->value" => [ + $type === Type::Number ? [Type::Number] : [], + $typeToCheck, + new Partial\Schema( + type: Type::Number->value, + anyOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ] + ) + ]; + + yield "$typeToCheck->value? top-level type: array, oneOf MUST be $type->value" => [ + $type === Type::Array ? [Type::Array] : [], + $typeToCheck, + new Partial\Schema( + type: Type::Array->value, + oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ] + ) + ]; + + + if ($type !== Type::String) { + yield "$typeToCheck->value? anyOf MAY be $type->value|string" => [ + [$type, Type::String], + $typeToCheck, + new Partial\Schema(anyOf: [ + new Partial\Schema(type: 'string'), + new Partial\Schema(type: $type->value), + ]) + ]; + } + + if ($type !== Type::Boolean) { + yield "$typeToCheck->value? oneOf MAY be $type->value|boolean" => [ + [$type, Type::Boolean], + $typeToCheck, + new Partial\Schema(oneOf: [ + new Partial\Schema(type: 'boolean'), + new Partial\Schema(type: $type->value), + ]) + ]; + } + + if ($type !== Type::Integer) { + yield "can it be $typeToCheck->value? allOf contains oneOf that may be $type->value|integer" => [ + [$type, Type::Integer], + $typeToCheck, + new Partial\Schema(allOf: [ + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: 'integer') + ]), + ]) + ]; + } + } + } + } + + /** + * @return Generator + */ + public static function provideSchemasAcceptNoTypes(): Generator + { + foreach (self::provideSchemasToCheckTypes() as $case => $dataSet) { + yield $case => [ + empty($dataSet[0]), + $dataSet[2] + ]; + } + } + + /** + * @return Generator + */ + public static function provideSchemasWithMax(): Generator + { + yield 'no max' => [ + null, + new Partial\Schema( + exclusiveMaximum: null, + maximum: null, + ), + ]; + + yield 'inclusive max' => [ + new Limit(1, false), + new Partial\Schema( + exclusiveMaximum: null, + maximum: 1, + ), + ]; + + yield 'exclusive max' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMaximum: 1, + maximum: null, + ), + ]; + + yield 'exclusive wins when equal' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMaximum: 1, + maximum: 1, + ), + ]; + + yield 'exclusive wins when lower' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMaximum: 1, + maximum: 2, + ), + ]; + + yield 'inclusive wins when lower' => [ + new Limit(1, false), + new Partial\Schema( + exclusiveMaximum: 3, + maximum: 1, + ), + ]; + } + + /** + * @return Generator + */ + public static function provideSchemasWithMin(): Generator + { + yield 'no min' => [ + null, + new Partial\Schema( + exclusiveMinimum: null, + minimum: null, + ), + ]; + + yield 'inclusive min' => [ + new Limit(1, false), + new Partial\Schema( + exclusiveMinimum: null, + minimum: 1, + ), + ]; + + yield 'exclusive min' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMinimum: 1, + minimum: null, + ), + ]; + + yield 'exclusive wins when equal' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMinimum: 1, + minimum: 1, + ), + ]; + + yield 'exclusive wins when higher' => [ + new Limit(3, true), + new Partial\Schema( + exclusiveMinimum: 3, + minimum: 1, + ), + ]; + + yield 'inclusive wins when higher' => [ + new Limit(3, false), + new Partial\Schema( + exclusiveMinimum: 1, + minimum: 3, + ), + ]; + } + + /** + * @return \Generator + */ + public static function provideSchemasToGetTypes(): Generator + { + yield 'no type' => [ + [], + new Partial\Schema(), + ]; + + yield 'nullable' => [ + [], + new Partial\Schema(nullable: true) + ]; + + yield 'string' => [[Type::String], new Partial\Schema(type: 'string')]; + yield 'integer' => [[Type::Integer], new Partial\Schema(type: 'integer')]; + yield 'number' => [[Type::Number], new Partial\Schema(type: 'number')]; + yield 'boolean' => [[Type::Boolean], new Partial\Schema(type: 'boolean')]; + yield 'array' => [ + [Type::Array], + new Partial\Schema(type: 'array', items: new Partial\Schema()), + ]; + yield 'object' => [[Type::Object], new Partial\Schema(type: 'object')]; + + yield 'nullable string' => [ + [Type::String, Type::Null], + new Partial\Schema(type: ['string', 'null']), + ]; + yield 'nullable integer' => [ + [Type::Integer, Type::Null], + new Partial\Schema(type: ['integer', 'null']), + ]; + yield 'nullable number' => [ + [Type::Number, Type::Null], + new Partial\Schema(type: ['number', 'null']), + ]; + yield 'nullable boolean' => [ + [Type::Boolean, Type::Null], + new Partial\Schema(type: ['boolean', 'null']), + ]; + yield 'nullable array' => [ + [Type::Array, Type::Null], + new Partial\Schema(type: ['array', 'null'], items: new Partial\Schema()), + ]; + yield 'nullable object' => [ + [Type::Object, Type::Null], + new Partial\Schema(type: ['object', 'null']), + ]; + } +} diff --git a/tests/fixtures/Helper/OpenAPIProvider.php b/tests/fixtures/Helper/OpenAPIProvider.php index b0ff2fb..8f62d10 100644 --- a/tests/fixtures/Helper/OpenAPIProvider.php +++ b/tests/fixtures/Helper/OpenAPIProvider.php @@ -211,7 +211,7 @@ enum: ['2.0', '2.1', '2.2'], name: 'limit', in: 'query', required: false, - schema: PartialHelper::createSchema( + schema: new Partial\Schema( type: 'integer' ) ), @@ -227,12 +227,12 @@ enum: ['2.0', '2.1', '2.2'], content: [ PartialHelper::createMediaType( mediaType: 'application/json', - schema: PartialHelper::createSchema( + schema: new Partial\Schema( allOf: [ - PartialHelper::createSchema( + new Partial\Schema( type: 'integer' ), - PartialHelper::createSchema( + new Partial\Schema( type: 'number' ) ] @@ -258,7 +258,7 @@ enum: ['2.0', '2.1', '2.2'], name: 'limit', in: 'query', required: false, - schema: PartialHelper::createSchema( + schema: new Partial\Schema( type: 'integer' ) ), @@ -274,12 +274,12 @@ enum: ['2.0', '2.1', '2.2'], content: [ PartialHelper::createMediaType( mediaType: 'application/json', - schema: PartialHelper::createSchema( + schema: new Partial\Schema( allOf: [ - PartialHelper::createSchema( + new Partial\Schema( type: 'integer' ), - PartialHelper::createSchema( + new Partial\Schema( type: 'number' ) ] @@ -302,7 +302,7 @@ enum: ['2.0', '2.1', '2.2'], name: 'user', in: 'cookie', required: false, - schema: PartialHelper::createSchema( + schema: new Partial\Schema( type: 'object' ) ) diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index 1b26539..38b925c 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -169,10 +169,10 @@ public static function createSchema( array|null $allOf = null, array|null $anyOf = null, array|null $oneOf = null, - Schema|null $not = null, - string|null $format = null, - string|null $title = null, - string|null $description = null, + bool|Schema|null $not = null, + string $format = '', + string $title = '', + string $description = '', ): Schema { return new Schema( type: $type, diff --git a/tests/fixtures/ProvidesInvalidatedSchemas.php b/tests/fixtures/ProvidesInvalidatedSchemas.php new file mode 100644 index 0000000..4567cbe --- /dev/null +++ b/tests/fixtures/ProvidesInvalidatedSchemas.php @@ -0,0 +1,119 @@ + + */ + public static function forV3X(): Generator + { + $identifier = new Identifier('sut'); + + yield 'invalid type' => [ + InvalidOpenAPI::invalidType($identifier, 'invalid'), + $identifier, + new Partial\Schema(type: 'invalid'), + ]; + + yield 'properties without string keys' => [ + InvalidOpenAPI::mustHaveStringKeys($identifier, 'properties'), + $identifier, + new Partial\Schema(properties: [new Partial\Schema()]), + ]; + + yield 'negative maxLength' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger($identifier, 'maxLength'), + $identifier, + new Partial\Schema(maxLength: -1), + ]; + + yield 'negative maxItems' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger($identifier, 'maxItems'), + $identifier, + new Partial\Schema(maxItems: -1), + ]; + + yield 'negative maxProperties' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger($identifier, 'maxProperties'), + $identifier, + new Partial\Schema(maxProperties: -1), + ]; + + yield 'zero multipleOf' => [ + InvalidOpenAPI::keywordCannotBeZero($identifier, 'multipleOf'), + $identifier, + new Partial\Schema(multipleOf: 0), + ]; + + yield 'default does not conform to type' => [ + InvalidOpenAPI::defaultMustConformToType($identifier), + $identifier, + new Partial\Schema(type: 'string', default: new Value(1)), + ]; + } + + /** + * @return Generator + */ + public static function forV30(): Generator + { + $identifier = new Identifier('sut'); + + yield 'numeric exclusiveMaximum' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30($identifier, 'exclusiveMaximum'), + $identifier, + new Partial\Schema(exclusiveMaximum: 5), + ]; + + yield 'numeric exclusiveMinimum' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30($identifier, 'exclusiveMinimum'), + $identifier, + new Partial\Schema(exclusiveMinimum: 5), + ]; + } + + /** + * @return Generator + */ + public static function forV31(): Generator + { + $identifier = new Identifier('sut'); + + yield 'numeric exclusiveMaximum' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31($identifier, 'exclusiveMaximum'), + $identifier, + new Partial\Schema(exclusiveMaximum: true), + ]; + + yield 'numeric exclusiveMinimum' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31($identifier, 'exclusiveMinimum'), + $identifier, + new Partial\Schema(exclusiveMinimum: true), + ]; + } +} diff --git a/tests/fixtures/ProvidesReviewedSchemas.php b/tests/fixtures/ProvidesReviewedSchemas.php new file mode 100644 index 0000000..3c23061 --- /dev/null +++ b/tests/fixtures/ProvidesReviewedSchemas.php @@ -0,0 +1,58 @@ + + */ + public static function forV3X(): Generator + { + yield 'empty allOf' => [ + new Partial\Schema(allOf: []), + new Warning('allOf must not be empty', Warning::INVALID), + ]; + yield 'empty anyOf' => [ + new Partial\Schema(anyOf: []), + new Warning('anyOf must not be empty', Warning::INVALID), + ]; + yield 'empty oneOf' => [ + new Partial\Schema(oneOf: []), + new Warning('oneOf must not be empty', Warning::INVALID), + ]; + + yield 'negative minLength' => [ + new Partial\Schema(minLength: -1), + new Warning('minLength must not be negative', Warning::INVALID), + ]; + yield 'negative minItems' => [ + new Partial\Schema(minItems: -1), + new Warning('minItems must not be negative', Warning::INVALID), + ]; + yield 'negative minProperties' => [ + new Partial\Schema(minProperties: -1), + new Warning('minProperties must not be negative', Warning::INVALID), + ]; + + yield 'empty required' => [ + new Partial\Schema(required: []), + new Warning('required must not be empty', Warning::INVALID), + ]; + yield 'required contains duplicates' => [ + new Partial\Schema(required: ['id', 'id']), + new Warning('required must not contain duplicates', Warning::INVALID), + ]; + } +} diff --git a/tests/fixtures/ProvidesSimplifiedSchemas.php b/tests/fixtures/ProvidesSimplifiedSchemas.php new file mode 100644 index 0000000..ddb943b --- /dev/null +++ b/tests/fixtures/ProvidesSimplifiedSchemas.php @@ -0,0 +1,113 @@ + + */ + public static function forV3X(): Generator + { + yield 'allOf: null becomes array' => [new Partial\Schema(allOf: null), 'allOf', []]; + yield 'anyOf: null becomes array' => [new Partial\Schema(anyOf: null), 'anyOf', []]; + yield 'oneOf: null becomes array' => [new Partial\Schema(oneOf: null), 'oneOf', []]; + + yield 'minLength: negatives become zero' => [new Partial\Schema(minLength: -1), 'minLength', 0]; + yield 'minItems: negatives become zero' => [new Partial\Schema(minItems: -1), 'minItems', 0]; + yield 'minProperties: negatives become zero' => [new Partial\Schema(minProperties: -1), 'minProperties', 0]; + + yield 'required: null becomes array' => [new Partial\Schema(required: null), 'required', []]; + yield 'required: duplicates are removed' => [new Partial\Schema(required: ['id', 'id']), 'required', ['id']]; + } + + /** + * @return Generator + */ + public static function forV30(): Generator + { + yield 'minimum combined: inclusive 1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: false), + 'minimum', + new Limit(1, false), + ]; + yield 'minimum combined: exclusive 1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: true), + 'minimum', + new Limit(1, true), + ]; + yield 'maximum combined: inclusive 1' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: false), + 'maximum', + new Limit(1, false), + ]; + yield 'maximum combined: exclusive 1' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: true), + 'maximum', + new Limit(1, true), + ]; + } + + /** + * @return Generator + */ + public static function forV31(): Generator + { + yield 'exclusiveMinimum:5 overrides minimum:1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: 5), + 'minimum', + new Limit(5, true), + ]; + yield 'exclusiveMinimum:1 overrides minimum:1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: 1), + 'minimum', + new Limit(1, true), + ]; + yield 'minimum:5 overrides exclusiveMinimum:1' => [ + new Partial\Schema(minimum: 5, exclusiveMinimum: 1), + 'minimum', + new Limit(5, false), + ]; + + yield 'exclusiveMaximum:1 overrides maximum:5' => [ + new Partial\Schema(maximum: 5, exclusiveMaximum: 1), + 'maximum', + new Limit(1, true), + ]; + yield 'exclusiveMaximum:1 overrides maximum:1' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: 1), + 'maximum', + new Limit(1, true), + ]; + yield 'maximum:1 overrides exclusiveMaximum:5' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: 5), + 'maximum', + new Limit(1, false), + ]; + + yield 'const overrides enum' => [ + new Partial\Schema(const: new Value(3), enum: [new Value(1), new Value(2), new Value(3)]), + 'enum', + [new Value(3)], + ]; + } +}