diff --git a/phpstan.neon b/phpstan.neon index c158993..bde053c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,3 +2,4 @@ parameters: level: 9 paths: - src + treatPhpDocTypesAsCertain: false diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 7b65e49..9a51323 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -311,17 +311,100 @@ public static function typeArrayInWrongVersion(Identifier $identifier): self return new self($message); } + public static function numericExclusiveMinMaxIn30( + Identifier $identifier, + string $keyword, + ): self { + $message = <<servers) === 1 && $openApi->servers[0]->url === '/' ? [] : $openApi->servers; @@ -30,7 +31,7 @@ public static function createOpenAPI( * The reason for this is the cebe library does not specify that info is nullable * However it is not always set, so it can be null */ - return Valid\V30\OpenAPI::fromPartial(new OpenAPI( + return V30\OpenAPI::fromPartial(new OpenAPI( $openApi->openapi, $openApi->info?->title, // @phpstan-ignore-line $openApi->info?->version, // @phpstan-ignore-line @@ -143,10 +144,47 @@ private static function createSchema( ); return new Schema( - $schema->type, - isset($schema->allOf) ? $createSchemas($schema->allOf) : null, - isset($schema->anyOf) ? $createSchemas($schema->anyOf) : null, - isset($schema->oneOf) ? $createSchemas($schema->oneOf) : null, + type: $schema->type, + enum: $schema->enum ?? null, + const: $schema->const ?? null, + default: isset($schema->default) ? new Value($schema->default) : null, + nullable: $schema->nullable ?? false, + multipleOf: $schema->multipleOf ?? null, + exclusiveMaximum: $schema->exclusiveMaximum ?? false, + exclusiveMinimum: $schema->exclusiveMinimum ?? false, + maximum: $schema->maximum ?? null, + minimum: $schema->minimum ?? null, + maxLength: $schema->maxLength ?? null, + minLength: $schema->minLength ?? 0, + pattern: $schema->pattern ?? null, + maxItems: $schema->maxItems ?? null, + minItems: $schema->minItems ?? 0, + uniqueItems: $schema->uniqueItems ?? false, + maxContains: $schema->maxContains ?? null, + minContains: $schema->minContains ?? null, + maxProperties: $schema->maxProperties ?? null, + minProperties: $schema->minProperties ?? 0, + required: $schema->required ?? null, + dependentRequired: $schema->dependentRequired ?? null, + allOf: isset($schema->allOf) ? $createSchemas($schema->allOf) : null, + anyOf: isset($schema->anyOf) ? $createSchemas($schema->anyOf) : null, + oneOf: isset($schema->oneOf) ? $createSchemas($schema->oneOf) : null, + not: isset($schema->not) ? self::createSchema($schema->not) : null, + if: isset($schema->if) ? self::createSchema($schema->if) : null, + then: isset($schema->then) ? self::createSchema($schema->then) : null, + else: isset($schema->else) ? self::createSchema($schema->else) : null, + dependentSchemas: isset($schema->dependentSchemas) ? + $createSchemas($schema->dependentSchemas) : + null, + items: isset($schema->items) ? (is_array($schema->items) ? + $createSchemas($schema->items) : + self::createSchema($schema->items)) : + null, + properties: isset($schema->properties) ? $createSchemas($schema->properties) : null, + additionalProperties: isset($schema->additionalProperties) ? (is_bool($schema->additionalProperties) ? + $schema->additionalProperties : + self::createSchema($schema->additionalProperties) ?? true) : + true, ); } diff --git a/src/ValueObject/Limit.php b/src/ValueObject/Limit.php new file mode 100644 index 0000000..4335eb8 --- /dev/null +++ b/src/ValueObject/Limit.php @@ -0,0 +1,14 @@ + */ + public null|string|array $type = null, + /** @var array|null */ + public array|null $enum = null, + public Value|null $const = null, + public Value|null $default = null, + /** + * 3.0 keywords that are extensions to the spec + * https://github.com/OAI/OpenAPI-Specification/blob/3.1.1/versions/3.0.4.md#fixed-fields-21 + */ + public bool $nullable = false, + /** + * Keywords for numeric type + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#autoid-11 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-num + */ + public float|int|null $multipleOf = null, + public bool|float|int|null $exclusiveMaximum = null, + public bool|float|int|null $exclusiveMinimum = null, + public float|int|null $maximum = null, + public float|int|null $minimum = null, + /** + * Keywords for string type + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#autoid-11 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-str + */ + public int|null $maxLength = null, + public int $minLength = 0, + public string|null $pattern = null, + /** + * Keywords for array type + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#autoid-11 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-arr + */ + public int|null $maxItems = null, + public int $minItems = 0, + public bool $uniqueItems = false, + public int|null $maxContains = null, + public int|null $minContains = null, + /** + * Keywords for object type + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#autoid-11 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-obj + */ + public int|null $maxProperties = null, + public int $minProperties = 0, + /** @var array|null */ + public array|null $required = null, + /** @var array>|null */ + public array|null $dependentRequired = null, + /** + * Keywords for applying subschemas with logic + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-core#name-keywords-for-applying-subsch + */ + /** @var array|null */ + public array|null $allOf = null, + /** @var array|null */ + public array|null $anyOf = null, + /** @var array|null */ + public array|null $oneOf = null, + public 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 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-core#name-keywords-for-applying-subsche + */ + public Schema|null $if = null, + public Schema|null $then = null, + public Schema|null $else = null, + /** @var array|null */ + public array|null $dependentSchemas = null, + /** + * Keywords for applying subschemas to arrays + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-core#name-keywords-for-applying-subschema + */ + /** @var array */ + public array|null $prefixItems = null, + /** @var array|Schema|null */ + public array|Schema|null $items = null, + public Schema|null $contains = null, + /** + * Keywords for applying subschemas to arrays + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-core#name-keywords-for-applying-subschemas + */ + /** @var array|null */ + public array|null $properties = [], + /** @var array */ + public array $patternProperties = [], + public bool|Schema $additionalProperties = true, + public bool|Schema $propertyNames = true, + /** + * Keywords that are exceptions to the usual "keyword independence" + * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 + * 3.1 https://json-schema.org/draft/2020-12/json-schema-core#name-keyword-independence-2 + */ + public bool|Schema $unevaluatedItems = true, + public bool|Schema $unevaluatedProperties = true, ) { } } diff --git a/src/ValueObject/Valid/Schema.php b/src/ValueObject/Valid/Schema.php new file mode 100644 index 0000000..4aff3f8 --- /dev/null +++ b/src/ValueObject/Valid/Schema.php @@ -0,0 +1,13 @@ +|null */ + public readonly array|null $enum; + public readonly Value|null $default; - /** - * If specified, this keyword's value MUST be a non-empty array. - * @var ?array - */ - public readonly ?array $allOf; + 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; - /** - * If specified, this keyword's value MUST be a non-empty array. - * @var ?array - */ + public readonly int|null $maxLength; + public readonly int $minLength; + public readonly string|null $pattern; + + /** @var array|Schema|null */ + public readonly array|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; - /** - * If specified, this keyword's value MUST be a non-empty array. - * @var ?array - */ - public readonly ?array $oneOf; - /** - * @var Type[] - */ + /** @var Type[] */ private readonly array $typesItCanBe; public function __construct( @@ -50,33 +66,40 @@ public function __construct( parent::__construct($identifier); $this->type = $this->validateType($this->getIdentifier(), $schema->type); + $this->nullable = $schema->nullable; + $this->enum = $schema->enum; + $this->default = $schema->default; - if (isset($schema->allOf)) { - if (empty($schema->allOf)) { - throw InvalidOpenAPI::emptyComplexSchema($this->getIdentifier()); - } - $this->allOf = $this->getSubSchemas('allOf', $schema->allOf); - } else { - $this->allOf = null; - } + $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); - if (isset($schema->anyOf)) { - if (empty($schema->anyOf)) { - throw InvalidOpenAPI::emptyComplexSchema($this->getIdentifier()); - } - $this->anyOf = $this->getSubSchemas('anyOf', $schema->anyOf); - } else { - $this->anyOf = null; - } + $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength); + $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength) ?? 0; + $this->pattern = $schema->pattern; - if (isset($schema->oneOf)) { - if (empty($schema->oneOf)) { - throw InvalidOpenAPI::emptyComplexSchema($this->getIdentifier()); - } - $this->oneOf = $this->getSubSchemas('oneOf', $schema->oneOf); - } else { - $this->oneOf = null; - } + $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->typesItCanBe = array_map( fn($t) => Type::from($t), @@ -91,39 +114,6 @@ public function __construct( } } - /** @param array|string|null $type */ - private function validateType(Identifier $identifier, array|string|null $type): ?Type - { - if (is_null($type)) { - return null; - } - - if (is_array($type)) { - return throw InvalidOpenAPI::typeArrayInWrongVersion($this->getIdentifier()); - } - - return Type::tryFromVersion( - OpenAPIVersion::Version_3_0, - $type - ) ?? throw InvalidOpenAPI::invalidType($identifier, $type); - } - - /** - * @param Partial\Schema[] $subSchemas - * @return self[] - */ - private function getSubSchemas(string $keyword, array $subSchemas): array - { - $result = []; - foreach ($subSchemas as $index => $subSchema) { - $result[] = new Schema( - $this->getIdentifier()->append("$keyword($index)"), - $subSchema - ); - } - return $result; - } - public function canBe(Type $type): bool { return in_array($type, $this->typesItCanBe); @@ -145,6 +135,20 @@ public function canBePrimitive(): bool return false; } + 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 { @@ -177,4 +181,143 @@ private function typesItCanBe(): array 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 + { + 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'); + } + + 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 null|array $subSchemas + * @return array + */ + private function validateProperties(?array $subSchemas): array + { + $subSchemas ??= []; + + $result = []; + foreach ($subSchemas as $index => $subSchema) { + $result[] = new Schema( + $this->getIdentifier()->append("properties($index)"), + $subSchema + ); + } + + return $result; + } + + /** + * @param array|Partial\Schema|null $items + * @return array|Schema|null + */ + private function validateItems( + Type|null $type, + array|Partial\Schema|null $items, + ): array|Schema|null { + if (is_null($items)) { + //@todo update tests to support this validation + //if ($type == Type::Array) { + // throw InvalidOpenAPI::mustSpecifyItemsForArrayType($this->getIdentifier()); + //} + + return $items; + } + + if (is_array($items)) { + return $this->validateSubSchemas('items', $items); + } + + return new Schema($this->getIdentifier()->append('items'), $items); + } } diff --git a/src/ValueObject/Value.php b/src/ValueObject/Value.php new file mode 100644 index 0000000..9f6a6ea --- /dev/null +++ b/src/ValueObject/Value.php @@ -0,0 +1,13 @@ + [ - InvalidOpenAPI::emptyComplexSchema($exceptionId), + $case = fn(Identifier $exceptionId, Partial\Schema $schema, string $keyword) => [ + InvalidOpenAPI::mustBeNonEmpty($exceptionId, $keyword), $identifier, $schema ]; foreach ($xOfs as $keyword => $xOf) { - yield "empty $keyword" => $case($identifier, $xOf()); + yield "empty $keyword" => $case($identifier, $xOf(), $keyword); foreach ($xOfs as $otherKeyWord => $otherXOf) { yield "$keyword with empty $otherKeyWord inside" => $case( $identifier->append($keyword, '0'), $xOf($otherXOf()), + $otherKeyWord, ); } } diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index bee127e..d77eafa 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -137,10 +137,10 @@ public static function createSchema( ?array $oneOf = null, ): Schema { return new Schema( - $type, - $allOf, - $anyOf, - $oneOf, + type: $type, + allOf: $allOf, + anyOf: $anyOf, + oneOf: $oneOf, ); } }