diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 0deac79..c105e11 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -13,6 +13,6 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1', '8.2', '8.3', '8.4'] + ['8.3', '8.4'] stability: >- ['prefer-lowest', 'prefer-stable'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a43d0..a4adfaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog -## 1.0.0 - Unreleased +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/). + +--- + +## [1.3.0] – 2025-07-21 + +### Added +- Support for nullable enums. +- Better property definition accuracy. + +### Fixed +- Psalm warnings for Symfony 7.3. + +--- + +## [1.2.0] – 2025-06-02 + +### Added +- `format` parameter added to the `Field` attribute. + +--- + +## [1.1.1] – 2025-04-29 + +### Changed +- Upgraded `phpdoc-parser` dependency for improved PHPDoc handling. + +--- + +## [1.1.0] – 2023-12-06 + +### Added +- Compatibility with `symfony/property-info` v7.0. + +### Fixed +- `.gitattributes` configuration. + +--- + +## [1.0.0] – 2023-11-26 + +### Added +- Initial release. +- JSON Schema generator for PHP. +- Primary use case: structured output generation for LLM-based systems. + +### Features +- PHP native type support. +- Nested objects and list (array) support. +- Psalm type annotations support. +- Custom metadata via PHP attributes. +- Enum support. + +## [2.0.0] – Unreleased + +### Added +- Compatibility with Symfony 7.2 and newer. + +> **Note** +> This version takes advantage of updated type system features and is intended for use with modern Symfony applications. -- initial release diff --git a/README.md b/README.md index 47aa3b0..c4af6d8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Total Downloads](https://poser.pugx.org/spiral/json-schema-generator/downloads)](https://packagist.org/packages/spiral/json-schema-generator) [![psalm-level](https://shepherd.dev/github/spiral/json-schema-generator/level.svg)](https://shepherd.dev/github/spiral/json-schema-generator) -The JSON Schema Generator is a PHP package that simplifies the generation of [JSON schemas](https://json-schema.org/) from Data Transfer Object (DTO) classes. +The JSON Schema Generator is a PHP package that simplifies the generation of [JSON schemas](https://json-schema.org/) from Data Transfer Object (DTO) classes. It supports PHP enumerations and generic type annotations for arrays and provides an attribute for specifying title, description, and default value. Main use case - structured output definition for LLMs. @@ -16,7 +16,7 @@ Main use case - structured output definition for LLMs. Make sure that your server is configured with the following PHP versions and extensions: -- PHP >=8.1 +- PHP >=8.3 ## Installation @@ -107,17 +107,34 @@ Example array output: 'description' => [ 'title' => 'Description', 'description' => 'The description of the movie', - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], - 'director' => [ - 'type' => 'string', + 'director' => [ + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], 'releaseStatus' => [ 'title' => 'Release Status', 'description' => 'The release status of the movie', - 'allOf' => [ + 'oneOf' => [ [ - '$ref' => '#/definitions/ReleaseStatus', + 'type' => 'null', + ], + [ + 'type' => 'string', + 'enum' => [ + 'Released', + 'Rumored', + 'Post Production', + 'In Production', + 'Planned', + 'Canceled', + ], ], ], ], @@ -126,20 +143,6 @@ Example array output: 'title', 'year', ], - 'definitions' => [ - 'ReleaseStatus' => [ - 'title' => 'ReleaseStatus', - 'type' => 'string', - 'enum' => [ - 'Released', - 'Rumored', - 'Post Production', - 'In Production', - 'Planned', - 'Canceled', - ], - ], - ], ]; ``` @@ -159,7 +162,8 @@ final class Actor /** * @var array */ - public readonly array $movies = [], + public readonly ?array $movies = null, + public readonly ?Movie $bestMovie = null; ) { } } @@ -191,11 +195,29 @@ Example array output: 'type' => 'string', ], 'movies' => [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/definitions/Movie', + 'oneOf' => [ + [ + 'type' => 'null', + ], + [ + 'type' => 'array', + 'items' => [ + '$ref' => '#/definitions/Movie', + ], + ], + ], + ], + 'bestMovie' => [ + 'title' => 'Best Movie', + 'description' => 'The best movie of the actor', + 'oneOf' => [ + [ + 'type' => 'null', + ], + [ + '$ref' => '#/definitions/Movie', + ], ], - 'default' => [], ], ], 'required' => [ @@ -219,19 +241,25 @@ Example array output: 'description' => [ 'title' => 'Description', 'description' => 'The description of the movie', - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], 'director' => [ - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], 'releaseStatus' => [ 'title' => 'Release Status', 'description' => 'The release status of the movie', - 'allOf' => [ - [ - '$ref' => '#/definitions/ReleaseStatus', - ], + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], ], + 'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'], ], ], 'required' => [ @@ -239,21 +267,166 @@ Example array output: 'year', ], ], - 'ReleaseStatus' => [ - 'title' => 'ReleaseStatus', - 'type' => 'string', - 'enum' => [ - 'Released', - 'Rumored', - 'Post Production', - 'In Production', - 'Planned', - 'Canceled', + ], +]; +``` + +## Polymorphic Arrays (anyOf) +The generator also supports arrays that contain different types of DTOs using PHPDoc annotations like **@var list**. +For example, if an actor can have a filmography that includes both movies and TV series, you can define it like this: +```php +namespace App\DTO; + +use Spiral\JsonSchemaGenerator\Attribute\Field; + +final class Actor +{ + public function __construct( + public readonly string $name, + public readonly int $age, + + #[Field(title: 'Biography', description: 'The biography of the actor')] + public readonly ?string $bio = null, + + /** + * @var list|null + */ + #[Field(title: 'Filmography', description: 'List of movies and series featuring the actor')] + public readonly ?array $filmography = null, + + #[Field(title: 'Best Movie', description: 'The best movie of the actor')] + public readonly ?Movie $bestMovie = null, + + #[Field(title: 'Best Series', description: 'The most prominent series of the actor')] + public readonly ?Series $bestSeries = null, + ) {} +} +``` +The generated schema will reflect this with an anyOf definition in the items section: +```php +[ + 'properties' => [ + 'filmography' => [ + 'title' => 'Filmography', + 'description' => 'List of movies and series featuring the actor', + 'oneOf' => [ + ['type' => 'null'], + [ + 'type' => 'array', + 'items' => [ + 'anyOf' => [ + ['$ref' => '#/definitions/Movie'], + ['$ref' => '#/definitions/Series'], + ], + ], + ], ], - ] + ], + ], + 'definitions' => [ + 'Movie' => [/* ... */], + 'Series' => [/* ... */], ], ]; ``` +## Example DTO: Series +Here's what the Series class might look like: +```php +namespace App\DTO; + +use Spiral\JsonSchemaGenerator\Attribute\Field; +use Spiral\JsonSchemaGenerator\Attribute\Format; + +final class Series +{ + public function __construct( + #[Field(title: 'Title', description: 'The title of the series')] + public readonly string $title, + + #[Field(title: 'First Air Year', description: 'The year the series first aired')] + public readonly int $firstAirYear, + + #[Field(title: 'Description', description: 'The description of the series')] + public readonly ?string $description = null, + + #[Field(title: 'Creator', description: 'The creator or showrunner of the series')] + public readonly ?string $creator = null, + + #[Field(title: 'Series Status', description: 'The current status of the series')] + public readonly ?SeriesStatus $status = null, + + #[Field(title: 'First Air Date', description: 'The original release date of the series', format: Format::Date)] + public readonly ?string $firstAirDate = null, + + #[Field(title: 'Last Air Date', description: 'The most recent air date of the series', format: Format::Date)] + public readonly ?string $lastAirDate = null, + + #[Field(title: 'Seasons', description: 'Number of seasons released')] + public readonly ?int $seasons = null, + ) {} +} +``` +> **Note** +> When using polymorphic arrays, make sure all referenced DTOs (e.g., Movie, Series) +> are also annotated properly so their definitions can be generated correctly. + +## Union Types +The JSON Schema Generator supports native PHP union types (introduced in PHP 8.0), including nullable and multi-type definitions. +Here's an example DTO using union types: +```php +namespace App\DTO; + +use Spiral\JsonSchemaGenerator\Attribute\Field; + +final class FlexibleValue +{ + public function __construct( + #[Field(title: 'Value', description: 'Can be either string or integer')] + public readonly string|int $value, + + #[Field(title: 'Optional Flag', description: 'Boolean or null')] + public readonly bool|null $flag = null, + + #[Field(title: 'Flexible Field', description: 'Can be string, int, or null')] + public readonly string|int|null $flex = null, + ) {} +} +``` +The generated schema will include a `oneOf` section to reflect the union types: +```php +[ + 'properties' => [ + 'value' => [ + 'title' => 'Value', + 'description' => 'Can be either string or integer', + 'oneOf' => [ + ['type' => 'string'], + ['type' => 'integer'], + ], + ], + 'flag' => [ + 'title' => 'Optional Flag', + 'description' => 'Boolean or null', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'boolean'], + ], + ], + 'flex' => [ + 'title' => 'Flexible Field', + 'description' => 'Can be string, int, or null', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ['type' => 'integer'], + ], + ], + ], + 'required' => ['value'], +] +``` +> **Note** +> All supported types are automatically resolved from native PHP type declarations and reflected in the JSON Schema output using oneOf. ## Testing diff --git a/composer.json b/composer.json index 0868f1c..93635a3 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,8 @@ } ], "require": { - "php": ">=8.1", - "symfony/property-info": "^6.4.18 || ^7.2", + "php": ">=8.3", + "symfony/property-info": "^7.2.0 || ^8.0.0", "phpstan/phpdoc-parser": "^1.33 | ^2.1", "phpdocumentor/reflection-docblock": "^5.3" }, diff --git a/src/Generator.php b/src/Generator.php index 9a7b2d4..1e4bb25 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -9,9 +9,11 @@ use Spiral\JsonSchemaGenerator\Parser\Parser; use Spiral\JsonSchemaGenerator\Parser\ParserInterface; use Spiral\JsonSchemaGenerator\Parser\PropertyInterface; -use Spiral\JsonSchemaGenerator\Parser\TypeInterface; +use Spiral\JsonSchemaGenerator\Parser\SimpleType; +use Spiral\JsonSchemaGenerator\Parser\Type; use Spiral\JsonSchemaGenerator\Schema\Definition; use Spiral\JsonSchemaGenerator\Schema\Property; +use Spiral\JsonSchemaGenerator\Schema\PropertyType; class Generator implements GeneratorInterface { @@ -83,14 +85,6 @@ public function generate(string|\ReflectionClass $class): Schema protected function generateDefinition(ClassParserInterface $class, array &$dependencies = []): ?Definition { $properties = []; - if ($class->isEnum()) { - return new Definition( - type: $class->getName(), - options: $class->getEnumValues(), - title: $class->getShortName(), - ); - } - // class properties foreach ($class->getProperties() as $property) { $psc = $this->generateProperty($property); @@ -127,24 +121,28 @@ protected function generateProperty(PropertyInterface $property): ?Property $type = $property->getType(); - $options = []; - if ($property->isCollection()) { - $options = \array_map( - static fn(TypeInterface $type) => $type->getName(), - $property->getCollectionValueTypes(), - ); - } - - $required = $default === null && !$type->allowsNull(); - if ($type->isBuiltin()) { - return new Property($type->getName(), $options, $title, $description, $required, $default, $format); - } - - // Class or enum - $class = $type->getName(); + return new Property( + types: $this->extractPropertyTypes($type), + title: $title, + description: $description, + required: $default === null && !$type->allowsNull(), + default: $default, + format: $format, + ); + } - return \is_string($class) && \class_exists($class) - ? new Property($class, [], $title, $description, $required, $default, $format) - : null; + /** + * @return list + */ + private function extractPropertyTypes(Type $type): array + { + return \array_map(static fn(SimpleType $simpleType) => new PropertyType( + type: $simpleType->getName(), + enum: $simpleType->getEnumValues(), + collectionTypes: $simpleType->isCollection() ? \array_map(static fn(SimpleType $collectionSimpleType) => new PropertyType( + type: $collectionSimpleType->getName(), + enum: $collectionSimpleType->getEnumValues(), + ), $simpleType->getCollectionType()?->types ?? []) : null, + ), $type->types); } } diff --git a/src/Parser/ClassParser.php b/src/Parser/ClassParser.php index e290880..7f896ca 100644 --- a/src/Parser/ClassParser.php +++ b/src/Parser/ClassParser.php @@ -5,12 +5,19 @@ namespace Spiral\JsonSchemaGenerator\Parser; use Spiral\JsonSchemaGenerator\Exception\GeneratorException; -use Spiral\JsonSchemaGenerator\Schema\Type as SchemaType; +use Spiral\JsonSchemaGenerator\Exception\InvalidTypeException; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\Type as TypeInfoType; /** * @internal @@ -24,7 +31,7 @@ final class ClassParser implements ClassParserInterface */ private array $constructorParameters = []; - private readonly PropertyInfoExtractorInterface $propertyInfo; + private readonly PropertyTypeExtractorInterface $propertyInfo; /** * @param \ReflectionClass|class-string $class @@ -42,10 +49,12 @@ public function __construct(\ReflectionClass|string $class) $this->class = $class; $this->propertyInfo = $this->createPropertyInfo(); - if ($this->class->hasMethod('__construct')) { - $constructor = $this->class->getMethod('__construct'); + $constructor = $this->class->getConstructor(); + if ($constructor !== null) { foreach ($constructor->getParameters() as $parameter) { - $this->constructorParameters[$parameter->getName()] = $parameter; + if ($parameter->isPromoted()) { + $this->constructorParameters[$parameter->getName()] = $parameter; + } } } } @@ -79,19 +88,18 @@ public function getProperties(): array } /** - * @var \ReflectionNamedType|null $type + * @var \ReflectionNamedType|\ReflectionUnionType|null $type */ $type = $property->getType(); - if (!$type instanceof \ReflectionNamedType) { + if (!$type instanceof \ReflectionNamedType && !$type instanceof \ReflectionUnionType) { continue; } $properties[] = new Property( property: $property, - type: new Type(name: $type->getName(), builtin: $type->isBuiltin(), nullable: $type->allowsNull()), + type: $this->getPropertyType($property), hasDefaultValue: $this->hasPropertyDefaultValue($property), defaultValue: $this->getPropertyDefaultValue($property), - collectionValueTypes: $this->getPropertyCollectionTypes($property->getName()), ); } @@ -103,56 +111,93 @@ public function isEnum(): bool return $this->class->isEnum(); } - public function getEnumValues(): array + /** + * @param non-empty-string|class-string $typeName + * + * @return list|null + */ + private function getEnumValues(string $typeName): ?array { - if (!$this->isEnum()) { - throw new GeneratorException(\sprintf('Class `%s` is not an enum.', $this->class->getName())); + if (!\is_subclass_of($typeName, \BackedEnum::class)) { + return null; } - $values = []; - foreach ($this->class->getReflectionConstants() as $constant) { - $value = $constant->getValue(); - \assert($value instanceof \BackedEnum); + $reflectionEnum = new \ReflectionEnum($typeName); + + return \array_map( + static fn(\ReflectionEnumUnitCase $case): int|string => $case->getValue()->value, + $reflectionEnum->getCases(), + ); + } + + private function getPropertyType(\ReflectionProperty $property): Type + { + $type = $this->propertyInfo->getType($property->class, $property->getName()); - $values[] = $value->value; + if ($type === null) { + throw new InvalidTypeException(); } - return $values; + return $this->createType($type); } - /** - * @param non-empty-string $property - * - * @return array - */ - private function getPropertyCollectionTypes(string $property): array + private function createType(TypeInfoType $type): Type { - $types = $this->propertyInfo->getTypes($this->class->getName(), $property); + $simpleTypes = []; + if ($type instanceof UnionType) { + foreach ($type->getTypes() as $subType) { + $simpleType = $this->createSimpleType($subType); + if ($simpleType !== null) { + $simpleTypes[] = $simpleType; + } + } + } else { + $simpleType = $this->createSimpleType($type); + if ($simpleType !== null) { + $simpleTypes[] = $simpleType; + } + } + + return new Type(types: $simpleTypes); + } - $collectionTypes = []; - foreach ($types ?? [] as $type) { - if ($type->isCollection()) { - $collectionTypes = [...$type->getCollectionValueTypes(), ...$collectionTypes]; + private function createSimpleType(TypeInfoType $type): ?SimpleType + { + $typeName = ''; + $builtin = true; + $enum = null; + $collectionType = null; + if ($type instanceof BuiltinType) { + if ($type->getTypeIdentifier() === TypeIdentifier::MIXED) { + return null; } + $typeName = $type->getTypeIdentifier()->value; + } + if ($type instanceof CollectionType) { + $typeName = TypeIdentifier::ARRAY->value; + $collectionType = $this->createType($type->getCollectionValueType()); + } + if ($type instanceof ObjectType) { + $typeName = $type->getClassName(); + $builtin = false; } - $result = []; - foreach ($collectionTypes as $type) { - /** - * @var non-empty-string $name - */ - $name = $type->getBuiltinType() === SchemaType::Object->value - ? $type->getClassName() - : $type->getBuiltinType(); - - $result[] = new Type( - name: $name, - builtin: $type->getBuiltinType() !== SchemaType::Object->value, - nullable: $type->isNullable(), - ); + if ($type instanceof BackedEnumType) { + $enum = $this->getEnumValues($type->getClassName()); + $typeName = $type->getBackingType()->getTypeIdentifier()->value; + $builtin = true; + } + + if ($typeName === '') { + throw new InvalidTypeException(); } - return $result; + return new SimpleType( + name: $typeName, + builtin: $builtin, + collectionType: $collectionType, + enum: $enum, + ); } private function hasPropertyDefaultValue(\ReflectionProperty $property): bool @@ -176,7 +221,7 @@ private function getPropertyDefaultValue(\ReflectionProperty $property): mixed return $default ?? null; } - private function createPropertyInfo(): PropertyInfoExtractorInterface + private function createPropertyInfo(): PropertyTypeExtractorInterface { return new PropertyInfoExtractor(typeExtractors: [ new PhpStanExtractor(), diff --git a/src/Parser/ClassParserInterface.php b/src/Parser/ClassParserInterface.php index ccb5f55..66d452b 100644 --- a/src/Parser/ClassParserInterface.php +++ b/src/Parser/ClassParserInterface.php @@ -20,8 +20,4 @@ public function getShortName(): string; * @return array */ public function getProperties(): array; - - public function isEnum(): bool; - - public function getEnumValues(): array; } diff --git a/src/Parser/Property.php b/src/Parser/Property.php index 804ab62..52638f0 100644 --- a/src/Parser/Property.php +++ b/src/Parser/Property.php @@ -4,8 +4,6 @@ namespace Spiral\JsonSchemaGenerator\Parser; -use Spiral\JsonSchemaGenerator\Schema\Type as SchemaType; - /** * @internal */ @@ -13,10 +11,9 @@ final class Property implements PropertyInterface { public function __construct( private readonly \ReflectionProperty $property, - private readonly TypeInterface $type, + private readonly Type $type, private readonly bool $hasDefaultValue, private readonly mixed $defaultValue = null, - private readonly array $collectionValueTypes = [], ) {} /** @@ -54,25 +51,7 @@ public function getDefaultValue(): mixed return $this->defaultValue; } - public function isCollection(): bool - { - $type = $this->type->getName(); - if (!$type instanceof SchemaType) { - return false; - } - - return $type->value === SchemaType::Array->value; - } - - /** - * @return array - */ - public function getCollectionValueTypes(): array - { - return $this->collectionValueTypes; - } - - public function getType(): TypeInterface + public function getType(): Type { return $this->type; } diff --git a/src/Parser/PropertyInterface.php b/src/Parser/PropertyInterface.php index adb332a..86a60a5 100644 --- a/src/Parser/PropertyInterface.php +++ b/src/Parser/PropertyInterface.php @@ -24,12 +24,5 @@ public function hasDefaultValue(): bool; public function getDefaultValue(): mixed; - public function isCollection(): bool; - - /** - * @return array - */ - public function getCollectionValueTypes(): array; - - public function getType(): TypeInterface; + public function getType(): Type; } diff --git a/src/Parser/SimpleType.php b/src/Parser/SimpleType.php new file mode 100644 index 0000000..c3acba2 --- /dev/null +++ b/src/Parser/SimpleType.php @@ -0,0 +1,68 @@ +|null $enum + */ + public function __construct( + string $name, + private readonly bool $builtin, + private readonly ?Type $collectionType = null, + private readonly ?array $enum = null, + ) { + /** @psalm-suppress PropertyTypeCoercion */ + $this->name = $this->builtin ? SchemaType::fromBuiltIn($name) : $name; + } + + /** + * @return class-string|SchemaType + */ + public function getName(): string|SchemaType + { + return $this->name; + } + + public function isBuiltin(): bool + { + return $this->builtin; + } + + public function isEnum(): bool + { + return $this->enum !== null; + } + + /** + * @return list|null + */ + public function getEnumValues(): ?array + { + return $this->enum; + } + + public function isCollection(): bool + { + return $this->collectionType !== null; + } + + public function getCollectionType(): ?Type + { + return $this->collectionType; + } +} diff --git a/src/Parser/Type.php b/src/Parser/Type.php index 93d9cbe..5d003f7 100644 --- a/src/Parser/Type.php +++ b/src/Parser/Type.php @@ -9,40 +9,17 @@ /** * @internal */ -final class Type implements TypeInterface +final class Type { /** - * @var class-string|SchemaType - */ - private string|SchemaType $name; - - /** - * @param non-empty-string|class-string $name + * @param list $types */ public function __construct( - string $name, - private readonly bool $builtin, - private readonly bool $nullable, - ) { - /** @psalm-suppress PropertyTypeCoercion */ - $this->name = $this->builtin ? SchemaType::fromBuiltIn($name) : $name; - } - - /** - * @return class-string|SchemaType - */ - public function getName(): string|SchemaType - { - return $this->name; - } - - public function isBuiltin(): bool - { - return $this->builtin; - } + public readonly array $types, + ) {} public function allowsNull(): bool { - return $this->nullable; + return \count(\array_filter($this->types, static fn(SimpleType $type): bool => $type->getName() === SchemaType::Null)) !== 0; } } diff --git a/src/Parser/TypeInterface.php b/src/Parser/TypeInterface.php index 4368167..9241125 100644 --- a/src/Parser/TypeInterface.php +++ b/src/Parser/TypeInterface.php @@ -15,5 +15,7 @@ public function getName(): string|SchemaType; public function isBuiltin(): bool; - public function allowsNull(): bool; + public function isEnum(): bool; + + public function getEnumValues(): ?array; } diff --git a/src/Schema.php b/src/Schema.php index 529912e..9bc84d7 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -18,7 +18,7 @@ public function addDefinition(string $name, Definition $definition): self public function jsonSerialize(): array { - $schema = $this->renderProperties([]); + $schema = $this->renderProperties(['type' => 'object']); if ($this->definitions !== []) { $schema['definitions'] = []; diff --git a/src/Schema/Definition.php b/src/Schema/Definition.php index feb21f8..51373c4 100644 --- a/src/Schema/Definition.php +++ b/src/Schema/Definition.php @@ -63,7 +63,7 @@ private function renderType(array $schema): array $rf = new \ReflectionClass($this->type); if (!$rf->isEnum()) { throw new DefinitionException(\sprintf( - 'Type `%s` must be a backed enum or class with properties.', + 'SimpleType `%s` must be a backed enum or class with properties.', $this->type instanceof Type ? $this->type->value : $this->type, )); } @@ -73,7 +73,7 @@ private function renderType(array $schema): array /** @var \ReflectionEnum $rf */ if (!$rf->isBacked()) { throw new DefinitionException(\sprintf( - 'Type `%s` is not a backed enum.', + 'SimpleType `%s` is not a backed enum.', $this->type instanceof Type ? $this->type->value : $this->type, )); } @@ -89,7 +89,7 @@ private function renderType(array $schema): array 'string' => 'string', 'bool' => 'boolean', default => throw new DefinitionException(\sprintf( - 'Type `%s` is not a backed enum.', + 'SimpleType `%s` is not a backed enum.', $this->type instanceof Type ? $this->type->value : $this->type, )), }; diff --git a/src/Schema/Property.php b/src/Schema/Property.php index 485799f..3a8def9 100644 --- a/src/Schema/Property.php +++ b/src/Schema/Property.php @@ -4,31 +4,19 @@ namespace Spiral\JsonSchemaGenerator\Schema; -use Spiral\JsonSchemaGenerator\Exception\InvalidTypeException; - final class Property implements \JsonSerializable { - public readonly PropertyOptions $options; - /** - * @param Type|class-string $type - * @param array $options + * @param list $types */ public function __construct( - public readonly Type|string $type, - array $options = [], + public readonly array $types, public readonly string $title = '', public readonly string $description = '', public readonly bool $required = false, public readonly mixed $default = null, public readonly ?Format $format = null, - ) { - if (\is_string($this->type) && !\class_exists($this->type)) { - throw new InvalidTypeException('Invalid type definition.'); - } - - $this->options = new PropertyOptions($options); - } + ) {} public function jsonSerialize(): array { @@ -49,32 +37,13 @@ public function jsonSerialize(): array $property['format'] = $this->format->value; } - - if ($this->type === Type::Union) { - $property['anyOf'] = $this->options->jsonSerialize(); - return $property; - } - - if (\is_string($this->type)) { - // this is nested class - $property['allOf'][] = ['$ref' => (new Reference($this->type))->jsonSerialize()]; - return $property; - } - - $property['type'] = $this->type->value; - - if ($this->type === Type::Array) { - if (\count($this->options) === 1) { - if (\is_string($this->options[0]->value)) { - // reference to class - $property['items']['$ref'] = (new Reference($this->options[0]->value))->jsonSerialize(); - return $property; - } - - $property['items']['type'] = $this->options[0]->value->value; - } else { - $property['items']['anyOf'] = $this->options->jsonSerialize(); + $typesCount = \count($this->types); + if ($typesCount > 1) { + foreach ($this->types as $type) { + $property['oneOf'][] = $this->propertyTypeToDefinition($type); } + } elseif ($typesCount === 1) { + $property = \array_merge($property, $this->propertyTypeToDefinition($this->types[0])); } return $property; @@ -83,16 +52,61 @@ public function jsonSerialize(): array public function getDependencies(): array { $dependencies = []; - foreach ($this->options->getOptions() as $option) { - if (\is_string($option->value)) { - $dependencies[] = $option->value; + foreach ($this->types as $type) { + if (\is_string($type->type)) { + $dependencies[] = $type->type; + } + if ($type->type === Type::Array && $type->collectionTypes !== null && $type->collectionTypes !== []) { + foreach ($type->collectionTypes as $collectionType) { + if (\is_string($collectionType->type)) { + $dependencies[] = $collectionType->type; + } + } } } - if (\is_string($this->type)) { - $dependencies[] = $this->type; + return $dependencies; + } + + protected function propertyTypeToDefinition(PropertyType $propertyType): array + { + $property = []; + + if ($propertyType->type instanceof Type) { + $property['type'] = $propertyType->type->value; + if ($propertyType->enum !== null) { + $property['enum'] = $propertyType->enum; + } + if ($propertyType->type === Type::Array && $propertyType->collectionTypes !== null && $propertyType->collectionTypes !== []) { + $collectionTypeCount = \count($propertyType->collectionTypes); + if ($collectionTypeCount > 1) { + foreach ($propertyType->collectionTypes as $collectionType) { + if ($collectionType->type instanceof Type) { + $schemaType = ['type' => $collectionType->type->value]; + if ($collectionType->enum !== null) { + $schemaType['enum'] = $collectionType->enum; + } + $property['items']['anyOf'][] = $schemaType; + } else { + $property['items']['anyOf'][] = ['$ref' => (new Reference($collectionType->type))->jsonSerialize()]; + } + } + } elseif ($collectionTypeCount === 1) { + $collectionType = $propertyType->collectionTypes[0]; + if ($collectionType->type instanceof Type) { + $property['items'] = ['type' => $collectionType->type->value]; + if ($collectionType->enum !== null) { + $property['items']['enum'] = $collectionType->enum; + } + } else { + $property['items'] = ['$ref' => (new Reference($collectionType->type))->jsonSerialize()]; + } + } + } + } else { + $property['$ref'] = (new Reference($propertyType->type))->jsonSerialize(); } - return $dependencies; + return $property; } } diff --git a/src/Schema/PropertyType.php b/src/Schema/PropertyType.php new file mode 100644 index 0000000..4d6c618 --- /dev/null +++ b/src/Schema/PropertyType.php @@ -0,0 +1,19 @@ +|null $enum + * @param list|null $collectionTypes + */ + public function __construct( + public readonly string|Type $type, + public readonly ?array $enum = null, + public readonly ?array $collectionTypes = null, + ) {} +} diff --git a/src/Schema/Type.php b/src/Schema/Type.php index ed5e5c8..c50fe9c 100644 --- a/src/Schema/Type.php +++ b/src/Schema/Type.php @@ -14,7 +14,6 @@ enum Type: string case Array = 'array'; case Null = 'null'; case Union = 'union'; - case Enum = 'enum'; public static function fromBuiltIn(string $type): self { diff --git a/tests/Unit/Fixture/Actor.php b/tests/Unit/Fixture/Actor.php index 74a53b3..e1d6316 100644 --- a/tests/Unit/Fixture/Actor.php +++ b/tests/Unit/Fixture/Actor.php @@ -9,15 +9,21 @@ final class Actor { public function __construct( + #[Field(title: 'Name', description: 'The name of the actor')] public readonly string $name, + #[Field(title: 'Age', description: 'The age of the actor')] public readonly int $age, #[Field(title: 'Biography', description: 'The biography of the actor')] public readonly ?string $bio = null, + /** - * @var list + * @var list|null */ - public readonly array $movies = [], + #[Field(title: 'Filmography', description: 'List of movies and series featuring the actor')] + public readonly ?array $filmography = null, #[Field(title: 'Best Movie', description: 'The best movie of the actor')] public readonly ?Movie $bestMovie = null, + #[Field(title: 'Best Series', description: 'The most prominent series of the actor')] + public readonly ?Series $bestSeries = null, ) {} } diff --git a/tests/Unit/Fixture/FlexibleValue.php b/tests/Unit/Fixture/FlexibleValue.php new file mode 100644 index 0000000..ff6e44c --- /dev/null +++ b/tests/Unit/Fixture/FlexibleValue.php @@ -0,0 +1,19 @@ +assertEquals( [ - 'properties' => [ + 'type' => 'object', + 'properties' => [ 'title' => [ 'title' => 'Title', 'description' => 'The title of the movie', @@ -32,24 +34,37 @@ public function testGenerateMovie(): void 'description' => [ 'title' => 'Description', 'description' => 'The description of the movie', - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], 'director' => [ - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], 'releaseStatus' => [ 'title' => 'Release Status', 'description' => 'The release status of the movie', - 'allOf' => [ + 'oneOf' => [ + [ + 'type' => 'string', + 'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'], + ], [ - '$ref' => '#/definitions/ReleaseStatus', + 'type' => 'null', ], ], ], 'releaseDate' => [ - 'type' => 'string', - 'format' => 'date', - 'title' => 'Release date', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], + 'format' => 'date', + 'title' => 'Release date', 'description' => 'The release date of the movie', ], ], @@ -57,20 +72,6 @@ public function testGenerateMovie(): void 'title', 'year', ], - 'definitions' => [ - 'ReleaseStatus' => [ - 'title' => 'ReleaseStatus', - 'type' => 'string', - 'enum' => [ - 'Released', - 'Rumored', - 'Post Production', - 'In Production', - 'Planned', - 'Canceled', - ], - ], - ], ], $schema->jsonSerialize(), ); @@ -83,96 +84,240 @@ public function testGenerateActor(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ - 'name' => [ - 'type' => 'string', + 'name' => [ + 'type' => 'string', + 'title' => 'Name', + 'description' => 'The name of the actor', ], - 'age' => [ - 'type' => 'integer', + 'age' => [ + 'type' => 'integer', + 'title' => 'Age', + 'description' => 'The age of the actor', ], - 'bio' => [ + 'bio' => [ 'title' => 'Biography', 'description' => 'The biography of the actor', - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], - 'movies' => [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/definitions/Movie', + 'filmography' => [ + 'title' => 'Filmography', + 'description' => 'List of movies and series featuring the actor', + 'oneOf' => [ + [ + 'type' => 'array', + 'items' => [ + 'anyOf' => [ + ['$ref' => '#/definitions/Movie'], + ['$ref' => '#/definitions/Series'], + ], + ], + ], + ['type' => 'null'], ], - 'default' => [], ], 'bestMovie' => [ 'title' => 'Best Movie', 'description' => 'The best movie of the actor', - 'allOf' => [ - [ - '$ref' => '#/definitions/Movie', - ], + 'oneOf' => [ + ['$ref' => '#/definitions/Movie'], + ['type' => 'null'], + ], + ], + 'bestSeries' => [ + 'title' => 'Best Series', + 'description' => 'The most prominent series of the actor', + 'oneOf' => [ + ['$ref' => '#/definitions/Series'], + ['type' => 'null'], ], ], ], - 'required' => [ + 'required' => [ 'name', 'age', ], 'definitions' => [ - 'Movie' => [ + 'Movie' => [ 'title' => 'Movie', 'type' => 'object', 'properties' => [ - 'title' => [ + 'title' => [ 'title' => 'Title', 'description' => 'The title of the movie', 'type' => 'string', ], - 'year' => [ + 'year' => [ 'title' => 'Year', 'description' => 'The year of the movie', 'type' => 'integer', ], - 'description' => [ + 'description' => [ 'title' => 'Description', 'description' => 'The description of the movie', - 'type' => 'string', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], - 'director' => [ - 'type' => 'string', + 'director' => [ + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], + ], + 'releaseDate' => [ + 'title' => 'Release date', + 'description' => 'The release date of the movie', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], + 'format' => 'date', ], 'releaseStatus' => [ 'title' => 'Release Status', 'description' => 'The release status of the movie', - 'allOf' => [ + 'oneOf' => [ [ - '$ref' => '#/definitions/ReleaseStatus', + 'type' => 'string', + 'enum' => [ + 'Released', + 'Rumored', + 'Post Production', + 'In Production', + 'Planned', + 'Canceled', + ], ], + ['type' => 'null'], ], ], - 'releaseDate' => [ - 'type' => 'string', - 'format' => 'date', - 'title' => 'Release date', - 'description' => 'The release date of the movie', + ], + 'required' => ['title', 'year'], + ], + 'Series' => [ + 'title' => 'Series', + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'title' => 'Title', + 'description' => 'The title of the series', + 'type' => 'string', + ], + 'firstAirYear' => [ + 'title' => 'First Air Year', + 'description' => 'The year the series first aired', + 'type' => 'integer', + ], + 'description' => [ + 'title' => 'Description', + 'description' => 'The description of the series', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], + ], + 'creator' => [ + 'title' => 'Creator', + 'description' => 'The creator or showrunner of the series', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], + ], + 'status' => [ + 'title' => 'Series Status', + 'description' => 'The current status of the series', + 'oneOf' => [ + [ + 'type' => 'string', + 'enum' => [ + 'Airing', + 'Ended', + 'Canceled', + 'Upcoming', + 'Hiatus', + ], + ], + ['type' => 'null'], + ], + ], + 'firstAirDate' => [ + 'title' => 'First Air Date', + 'description' => 'The original release date of the series', + 'format' => 'date', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], ], + 'lastAirDate' => [ + 'title' => 'Last Air Date', + 'description' => 'The most recent air date of the series', + 'format' => 'date', + 'oneOf' => [ + ['type' => 'null'], + ['type' => 'string'], + ], + ], + 'seasons' => [ + 'title' => 'Seasons', + 'description' => 'Number of seasons released', + 'oneOf' => [ + ['type' => 'integer'], + ['type' => 'null'], + ], + ], + ], + 'required' => ['title', 'firstAirYear'], + ], + ], + ], + $schema->jsonSerialize(), + ); + } + + public function testGenerateFlexibleValue(): void + { + $generator = new Generator(); + $schema = $generator->generate(FlexibleValue::class); + + $this->assertEquals( + [ + 'type' => 'object', + 'properties' => [ + 'value' => [ + 'title' => 'Value', + 'description' => 'Can be either string or integer', + 'oneOf' => [ + ['type' => 'integer'], + ['type' => 'string'], ], - 'required' => [ - 'title', - 'year', + ], + 'flag' => [ + 'title' => 'Optional Flag', + 'description' => 'Boolean or null', + 'oneOf' => [ + ['type' => 'boolean'], + ['type' => 'null'], ], ], - 'ReleaseStatus' => [ - 'title' => 'ReleaseStatus', - 'type' => 'string', - 'enum' => [ - 'Released', - 'Rumored', - 'Post Production', - 'In Production', - 'Planned', - 'Canceled', + 'flex' => [ + 'title' => 'Flexible Field', + 'description' => 'Can be string, int, or null', + 'oneOf' => [ + ['type' => 'integer'], + ['type' => 'null'], + ['type' => 'string'], ], ], ], + 'required' => ['value'], ], $schema->jsonSerialize(), ); diff --git a/tests/Unit/Parser/ClassParserTest.php b/tests/Unit/Parser/ClassParserTest.php index 32cc10a..d82a21d 100644 --- a/tests/Unit/Parser/ClassParserTest.php +++ b/tests/Unit/Parser/ClassParserTest.php @@ -6,8 +6,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Spiral\JsonSchemaGenerator\Exception\GeneratorException; use Spiral\JsonSchemaGenerator\Parser\ClassParser; +use Spiral\JsonSchemaGenerator\Parser\SimpleType; use Spiral\JsonSchemaGenerator\Schema\Type; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Movie; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\ReleaseStatus; @@ -26,7 +26,7 @@ public static function collectionsDataProvider(): \Traversable */ public array $collection; }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class { @@ -35,7 +35,7 @@ public static function collectionsDataProvider(): \Traversable */ public array $collection; }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class { @@ -44,7 +44,7 @@ public static function collectionsDataProvider(): \Traversable */ public array $collection; }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class { @@ -53,7 +53,7 @@ public static function collectionsDataProvider(): \Traversable */ public array $collection; }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -64,7 +64,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -75,7 +75,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -86,7 +86,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -97,7 +97,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -108,7 +108,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -119,7 +119,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -130,7 +130,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; yield [ new class([]) { @@ -141,7 +141,7 @@ public function __construct( public array $collection, ) {} }, - [new \Spiral\JsonSchemaGenerator\Parser\Type(Movie::class, false, false)], + [new SimpleType(Movie::class, false)], ]; } @@ -166,40 +166,42 @@ public function testGetProperties(): void $properties = $parser->getProperties(); $this->assertSame('title', $properties[0]->getName()); - $this->assertSame(Type::String, $properties[0]->getType()->getName()); - $this->assertTrue($properties[0]->getType()->isBuiltin()); + $this->assertCount(1, $properties[0]->getType()->types); + $this->assertSame(Type::String, $properties[0]->getType()->types[0]->getName()); $this->assertFalse($properties[0]->getType()->allowsNull()); - $this->assertFalse($properties[0]->isCollection()); + $this->assertFalse($properties[0]->getType()->types[0]->isCollection()); $this->assertFalse($properties[0]->hasDefaultValue()); $this->assertSame('year', $properties[1]->getName()); - $this->assertSame(Type::Integer, $properties[1]->getType()->getName()); - $this->assertTrue($properties[1]->getType()->isBuiltin()); + $this->assertSame(Type::Integer, $properties[1]->getType()->types[0]->getName()); $this->assertFalse($properties[1]->getType()->allowsNull()); - $this->assertFalse($properties[1]->isCollection()); + $this->assertFalse($properties[1]->getType()->types[0]->isCollection()); $this->assertFalse($properties[1]->hasDefaultValue()); $this->assertSame('description', $properties[2]->getName()); - $this->assertSame(Type::String, $properties[2]->getType()->getName()); - $this->assertTrue($properties[2]->getType()->isBuiltin()); + $this->assertSame(Type::String, $properties[2]->getType()->types[1]->getName()); $this->assertTrue($properties[2]->getType()->allowsNull()); - $this->assertFalse($properties[2]->isCollection()); + $this->assertFalse($properties[2]->getType()->types[0]->isCollection()); $this->assertTrue($properties[2]->hasDefaultValue()); $this->assertNull($properties[2]->getDefaultValue()); $this->assertSame('director', $properties[3]->getName()); - $this->assertSame(Type::String, $properties[3]->getType()->getName()); - $this->assertTrue($properties[3]->getType()->isBuiltin()); + $this->assertSame(Type::String, $properties[3]->getType()->types[1]->getName()); $this->assertTrue($properties[3]->getType()->allowsNull()); - $this->assertFalse($properties[3]->isCollection()); + $this->assertFalse($properties[3]->getType()->types[0]->isCollection()); $this->assertTrue($properties[3]->hasDefaultValue()); $this->assertNull($properties[3]->getDefaultValue()); $this->assertSame('releaseStatus', $properties[4]->getName()); - $this->assertSame(ReleaseStatus::class, $properties[4]->getType()->getName()); - $this->assertFalse($properties[4]->getType()->isBuiltin()); + $this->assertSame(Type::String, $properties[4]->getType()->types[0]->getName()); + $this->assertSame(Type::Null, $properties[4]->getType()->types[1]->getName()); + $this->assertTrue($properties[4]->getType()->types[0]->isEnum()); + $this->assertEquals( + ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'], + $properties[4]->getType()->types[0]->getEnumValues(), + ); $this->assertTrue($properties[4]->getType()->allowsNull()); - $this->assertFalse($properties[4]->isCollection()); + $this->assertFalse($properties[4]->getType()->types[0]->isCollection()); $this->assertTrue($properties[4]->hasDefaultValue()); $this->assertNull($properties[4]->getDefaultValue()); } @@ -213,33 +215,10 @@ public function testIsEnum(): void $this->assertTrue($parser->isEnum()); } - public function testGetEnumValues(): void - { - $parser = new ClassParser(ReleaseStatus::class); - $this->assertSame( - [ - 'Released', - 'Rumored', - 'Post Production', - 'In Production', - 'Planned', - 'Canceled', - ], - $parser->getEnumValues(), - ); - } - - public function testGetEnumValuesException(): void - { - $parser = new ClassParser(Movie::class); - $this->expectException(GeneratorException::class); - $parser->getEnumValues(); - } - #[DataProvider('collectionsDataProvider')] - public function testGetPropertyCollectionTypes(object $class, array $expected): void + public function testGetPropertyCollectionTypes(object $class, ?array $expected): void { $parser = new ClassParser($class::class); - $this->assertEquals($expected, $parser->getProperties()[0]->getCollectionValueTypes()); + $this->assertEquals($expected, $parser->getProperties()[0]->getType()->types[0]->getCollectionType()?->types); } } diff --git a/tests/Unit/Parser/PropertyTest.php b/tests/Unit/Parser/PropertyTest.php index 539b7ba..3054638 100644 --- a/tests/Unit/Parser/PropertyTest.php +++ b/tests/Unit/Parser/PropertyTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use Spiral\JsonSchemaGenerator\Attribute\Field; use Spiral\JsonSchemaGenerator\Parser\Property; +use Spiral\JsonSchemaGenerator\Parser\SimpleType; use Spiral\JsonSchemaGenerator\Parser\Type; -use Spiral\JsonSchemaGenerator\Parser\TypeInterface; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Movie; final class PropertyTest extends TestCase @@ -16,9 +16,9 @@ final class PropertyTest extends TestCase public function testGetName(): void { $property = new Property( - new \ReflectionProperty(Movie::class, 'title'), - $this->createMock(TypeInterface::class), - false, + property: new \ReflectionProperty(class: Movie::class, property: 'title'), + type: new Type(types: []), + hasDefaultValue: false, ); $this->assertSame('title', $property->getName()); @@ -27,30 +27,30 @@ public function testGetName(): void public function testFindAttribute(): void { $property = new Property( - new \ReflectionProperty(Movie::class, 'title'), - $this->createMock(TypeInterface::class), - false, + property: new \ReflectionProperty(class: Movie::class, property: 'title'), + type: new Type(types: []), + hasDefaultValue: false, ); $this->assertEquals( new Field(title: 'Title', description: 'The title of the movie'), - $property->findAttribute(Field::class), + $property->findAttribute(name: Field::class), ); } public function testHasDefaultValue(): void { $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - $this->createMock(TypeInterface::class), - true, + property: new \ReflectionProperty(Movie::class, 'description'), + type: new Type([]), + hasDefaultValue: true, ); $this->assertTrue($property->hasDefaultValue()); $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - $this->createMock(TypeInterface::class), - false, + property: new \ReflectionProperty(Movie::class, 'description'), + type: new Type([]), + hasDefaultValue: false, ); $this->assertFalse($property->hasDefaultValue()); } @@ -58,17 +58,17 @@ public function testHasDefaultValue(): void public function testGetDefaultValue(): void { $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - $this->createMock(TypeInterface::class), - true, + property: new \ReflectionProperty(Movie::class, 'description'), + type: new Type([]), + hasDefaultValue: true, ); $this->assertNull($property->getDefaultValue()); $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - $this->createMock(TypeInterface::class), - true, - 'foo', + property: new \ReflectionProperty(Movie::class, 'description'), + type: new Type(types: []), + hasDefaultValue: true, + defaultValue: 'foo', ); $this->assertSame('foo', $property->getDefaultValue()); } @@ -76,53 +76,35 @@ public function testGetDefaultValue(): void public function testIsCollection(): void { $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - new Type('string', true, false), - true, + property: new \ReflectionProperty(class: Movie::class, property: 'description'), + type: new Type(types: [new SimpleType(name: 'string', builtin: true)]), + hasDefaultValue: true, ); - $this->assertFalse($property->isCollection()); + $this->assertFalse($property->getType()->types[0]->isCollection()); $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - new Type(Movie::class, false, false), - true, + property: new \ReflectionProperty(Movie::class, 'description'), + type: new Type(types: [new SimpleType(name: Movie::class, builtin: false)]), + hasDefaultValue: true, ); - $this->assertFalse($property->isCollection()); + $this->assertFalse($property->getType()->types[0]->isCollection()); $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - new Type('array', true, false), - true, + property: new \ReflectionProperty(class: Movie::class, property: 'description'), + type: new Type(types: [new SimpleType(name: 'array', builtin: true, collectionType: new Type(types: [new SimpleType(name: 'string', builtin: true)]))]), + hasDefaultValue: true, ); - $this->assertTrue($property->isCollection()); - } - - public function testGetCollectionValueTypes(): void - { - $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - $this->createMock(TypeInterface::class), - true, - ); - $this->assertSame([], $property->getCollectionValueTypes()); - - $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - $this->createMock(TypeInterface::class), - true, - null, - [new Type(Movie::class, false, false)], - ); - $this->assertEquals([new Type(Movie::class, false, false)], $property->getCollectionValueTypes()); + $this->assertTrue($property->getType()->types[0]->isCollection()); + $this->assertEquals('string', $property->getType()->types[0]->getCollectionType()?->types[0]?->getName()->value); } public function testGetType(): void { $property = new Property( - new \ReflectionProperty(Movie::class, 'description'), - new Type(Movie::class, false, false), - true, + property: new \ReflectionProperty(class: Movie::class, property: 'description'), + type: new Type(types: [new SimpleType(name: Movie::class, builtin: false)]), + hasDefaultValue: true, ); - $this->assertEquals(new Type(Movie::class, false, false), $property->getType()); + $this->assertEquals(new SimpleType(name: Movie::class, builtin: false), $property->getType()->types[0]); } } diff --git a/tests/Unit/Parser/TypeTest.php b/tests/Unit/Parser/TypeTest.php index fd3235b..084b074 100644 --- a/tests/Unit/Parser/TypeTest.php +++ b/tests/Unit/Parser/TypeTest.php @@ -5,6 +5,7 @@ namespace Spiral\JsonSchemaGenerator\Tests\Unit\Parser; use PHPUnit\Framework\TestCase; +use Spiral\JsonSchemaGenerator\Parser\SimpleType; use Spiral\JsonSchemaGenerator\Parser\Type; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Movie; @@ -12,28 +13,28 @@ final class TypeTest extends TestCase { public function testGetName(): void { - $type = new Type('string', true, false); + $type = new SimpleType(name: 'string', builtin: true); $this->assertSame(\Spiral\JsonSchemaGenerator\Schema\Type::String, $type->getName()); - $type = new Type(Movie::class, false, false); + $type = new SimpleType(name: Movie::class, builtin: false); $this->assertSame(Movie::class, $type->getName()); } public function testIsBuiltin(): void { - $type = new Type('string', true, false); + $type = new SimpleType(name: 'string', builtin: true); $this->assertTrue($type->isBuiltin()); - $type = new Type(Movie::class, false, false); + $type = new SimpleType(name: Movie::class, builtin: false); $this->assertFalse($type->isBuiltin()); } public function testAllowsNull(): void { - $type = new Type('string', true, true); + $type = new Type(types: [new SimpleType(name: 'null', builtin: true), new SimpleType(name: 'string', builtin: true)]); $this->assertTrue($type->allowsNull()); - $type = new Type(Movie::class, false, false); + $type = new Type(types: [new SimpleType(name: Movie::class, builtin: false)]); $this->assertFalse($type->allowsNull()); } } diff --git a/tests/Unit/Schema/DefinitionTest.php b/tests/Unit/Schema/DefinitionTest.php index e3267f9..b7fd368 100644 --- a/tests/Unit/Schema/DefinitionTest.php +++ b/tests/Unit/Schema/DefinitionTest.php @@ -8,6 +8,7 @@ use Spiral\JsonSchemaGenerator\Exception\DefinitionException; use Spiral\JsonSchemaGenerator\Schema\Definition; use Spiral\JsonSchemaGenerator\Schema\Property; +use Spiral\JsonSchemaGenerator\Schema\PropertyType; use Spiral\JsonSchemaGenerator\Schema\Type; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\InvalidEnum; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Movie; @@ -21,13 +22,13 @@ public function testClassDefinition(): void type: Movie::class, properties: [ 'title' => new Property( - type: Type::String, + types: [new PropertyType(type: Type::String)], title: 'Title', description: 'Title of the movie', required: true, ), 'status' => new Property( - type: ReleaseStatus::class, + types: [new PropertyType(type: Type::Null), new PropertyType(type: Type::String, enum: ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'])], title: 'Status', description: 'Status of the movie', required: true, @@ -39,16 +40,18 @@ public function testClassDefinition(): void 'type' => 'object', 'properties' => [ 'title' => [ - 'title' => 'Title', + 'title' => 'Title', 'description' => 'Title of the movie', - 'type' => 'string', + 'type' => 'string', ], 'status' => [ - 'title' => 'Status', + 'title' => 'Status', 'description' => 'Status of the movie', - 'allOf' => [ + 'oneOf' => [ + ['type' => 'null'], [ - '$ref' => '#/definitions/ReleaseStatus', + 'type' => 'string', + 'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'], ], ], ], diff --git a/tests/Unit/Schema/PropertyTest.php b/tests/Unit/Schema/PropertyTest.php index 5902443..f406c47 100644 --- a/tests/Unit/Schema/PropertyTest.php +++ b/tests/Unit/Schema/PropertyTest.php @@ -5,9 +5,10 @@ namespace Spiral\JsonSchemaGenerator\Tests\Unit\Schema; use PHPUnit\Framework\TestCase; -use Spiral\JsonSchemaGenerator\Exception\InvalidTypeException; +use Spiral\JsonSchemaGenerator\Parser\SimpleType; use Spiral\JsonSchemaGenerator\Schema\Format; use Spiral\JsonSchemaGenerator\Schema\Property; +use Spiral\JsonSchemaGenerator\Schema\PropertyType; use Spiral\JsonSchemaGenerator\Schema\Type; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Actor; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Movie; @@ -16,14 +17,14 @@ final class PropertyTest extends TestCase { public function testPropertyOnlyRequiredParams(): void { - $property = new Property(type: Type::String); + $property = new Property(types: [new PropertyType(type: Type::String)]); $this->assertEquals(['type' => 'string'], $property->jsonSerialize()); } public function testPropertyWithTitle(): void { - $property = new Property(type: Type::String, title: 'Some movie'); + $property = new Property(types: [new PropertyType(type: Type::String)], title: 'Some movie'); $this->assertEquals([ 'type' => 'string', @@ -33,7 +34,7 @@ public function testPropertyWithTitle(): void public function testPropertyWithDescription(): void { - $property = new Property(type: Type::String, description: 'Some description'); + $property = new Property(types: [new PropertyType(type: Type::String)], description: 'Some description'); $this->assertEquals([ 'type' => 'string', @@ -43,7 +44,7 @@ public function testPropertyWithDescription(): void public function testPropertyWithDefault(): void { - $property = new Property(type: Type::String, default: 'value'); + $property = new Property(types: [new PropertyType(type: Type::String)], default: 'value'); $this->assertEquals([ 'type' => 'string', @@ -54,12 +55,11 @@ public function testPropertyWithDefault(): void public function testPropertyWithUnionType(): void { $property = new Property( - type: Type::Union, - options: [Movie::class, Actor::class], + types: [new PropertyType(type: Movie::class), new PropertyType(type: Actor::class)], ); $this->assertEquals([ - 'anyOf' => [ + 'oneOf' => [ [ '$ref' => '#/definitions/Movie', ], @@ -73,23 +73,18 @@ public function testPropertyWithUnionType(): void public function testPropertyWithClassType(): void { $property = new Property( - type: Movie::class, + types: [new PropertyType(type: Movie::class)], ); $this->assertEquals([ - 'allOf' => [ - [ - '$ref' => '#/definitions/Movie', - ], - ], + '$ref' => '#/definitions/Movie', ], $property->jsonSerialize()); } public function testPropertyWithArrayTypeSingleClassElem(): void { $property = new Property( - type: Type::Array, - options: [Movie::class], + types: [new PropertyType(type: Type::Array, collectionTypes: [new PropertyType(type: Movie::class)])], title: 'Some movie', ); @@ -105,8 +100,7 @@ public function testPropertyWithArrayTypeSingleClassElem(): void public function testPropertyWithArrayTypeSingleScalarElem(): void { $property = new Property( - type: Type::Array, - options: [Type::String], + types: [new PropertyType(type: Type::Array, collectionTypes: [new PropertyType(type: Type::String)])], title: 'Some movie', ); @@ -122,8 +116,7 @@ public function testPropertyWithArrayTypeSingleScalarElem(): void public function testPropertyWithArrayTypeMultipleElems(): void { $property = new Property( - type: Type::Array, - options: [Movie::class, Type::String], + types: [new PropertyType(type: Type::Array, collectionTypes: [new PropertyType(type: Movie::class), new PropertyType(type: Type::String)])], title: 'Some movie', ); @@ -145,13 +138,13 @@ public function testPropertyWithArrayTypeMultipleElems(): void public function testInvalidTypeException(): void { - $this->expectException(InvalidTypeException::class); - new Property(type: 'foo'); + $this->expectException(\InvalidArgumentException::class); + new SimpleType(name: 'foo', builtin: true); } public function testPropertyWithFormat(): void { - $property = new Property(type: Type::String, title: 'Homepage', format: Format::Uri); + $property = new Property(types: [new PropertyType(type: Type::String)], title: 'Homepage', format: Format::Uri); $this->assertEquals([ 'type' => 'string', diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php index a7adf2f..84d6af9 100644 --- a/tests/Unit/SchemaTest.php +++ b/tests/Unit/SchemaTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Spiral\JsonSchemaGenerator\Schema; +use Spiral\JsonSchemaGenerator\Schema\PropertyType; use Spiral\JsonSchemaGenerator\Tests\Unit\Fixture\Movie; final class SchemaTest extends TestCase @@ -14,7 +15,7 @@ public function testEmpty(): void { $schema = new Schema(); - $this->assertSame([], $schema->jsonSerialize()); + $this->assertSame(['type' => 'object'], $schema->jsonSerialize()); } public function testStringProperty(): void @@ -23,7 +24,7 @@ public function testStringProperty(): void $schema->addProperty( 'name', new Schema\Property( - type: Schema\Type::String, + types: [new PropertyType(type: Schema\Type::String)], title: 'Name', description: 'Name of the user', required: true, @@ -32,6 +33,7 @@ public function testStringProperty(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'name' => [ 'title' => 'Name', @@ -51,9 +53,9 @@ public function testScalarProperties(): void { $schema = new Schema(); $schema->addProperty( - 'name', - new Schema\Property( - type: Schema\Type::String, + name: 'name', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::String)], title: 'Name', description: 'Name of the user', required: true, @@ -61,9 +63,9 @@ public function testScalarProperties(): void ); $schema->addProperty( - 'age', - new Schema\Property( - type: Schema\Type::Integer, + name: 'age', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Integer)], title: 'Age', description: 'Age of the user', required: true, @@ -71,9 +73,9 @@ public function testScalarProperties(): void ); $schema->addProperty( - 'height', - new Schema\Property( - type: Schema\Type::Number, + name: 'height', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Number)], title: 'Height', description: 'Height of the user', required: true, @@ -81,9 +83,9 @@ public function testScalarProperties(): void ); $schema->addProperty( - 'is_active', - new Schema\Property( - type: Schema\Type::Boolean, + name: 'is_active', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Boolean)], title: 'Is Active', description: 'Is the user active', required: false, @@ -92,6 +94,7 @@ public function testScalarProperties(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'name' => [ 'title' => 'Name', @@ -128,10 +131,9 @@ public function testArrayProperty(): void { $schema = new Schema(); $schema->addProperty( - 'hobbies', - new Schema\Property( - type: Schema\Type::Array, - options: [Schema\Type::String], + name: 'hobbies', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Array, collectionTypes: [new PropertyType(type: Schema\Type::String)])], title: 'Hobbies', description: 'Hobbies of the user', required: true, @@ -140,6 +142,7 @@ public function testArrayProperty(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'hobbies' => [ 'type' => 'array', @@ -162,10 +165,9 @@ public function testArrayPropertyWithMultipleTypes(): void { $schema = new Schema(); $schema->addProperty( - 'hobbies', - new Schema\Property( - type: Schema\Type::Array, - options: [Schema\Type::String, Schema\Type::Number], + name: 'hobbies', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Array, collectionTypes: [new PropertyType(type: Schema\Type::String), new PropertyType(type: Schema\Type::Number)])], title: 'Hobbies', description: 'Hobbies of the user', required: true, @@ -174,6 +176,7 @@ public function testArrayPropertyWithMultipleTypes(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'hobbies' => [ 'type' => 'array', @@ -195,48 +198,13 @@ public function testArrayPropertyWithMultipleTypes(): void ); } - public function testMixedProperty(): void - { - $schema = new Schema(); - $schema->addProperty( - 'hobbies', - new Schema\Property( - type: Schema\Type::Union, - options: [Schema\Type::String, Schema\Type::Number, Schema\Type::Boolean], - title: 'Some value', - description: 'Some random user value', - required: true, - ), - ); - - $this->assertEquals( - [ - 'properties' => [ - 'hobbies' => [ - 'title' => 'Some value', - 'description' => 'Some random user value', - 'anyOf' => [ - ['type' => 'string'], - ['type' => 'number'], - ['type' => 'boolean'], - ], - ], - ], - 'required' => [ - 'hobbies', - ], - ], - $schema->jsonSerialize(), - ); - } - public function testClassProperty(): void { $schema = new Schema(); $schema->addProperty( - 'movie', - new Schema\Property( - type: Movie::class, + name: 'movie', + property: new Schema\Property( + types: [new PropertyType(type: Movie::class)], title: 'Some movie', required: false, ), @@ -244,14 +212,11 @@ public function testClassProperty(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'movie' => [ 'title' => 'Some movie', - 'allOf' => [ - [ - '$ref' => '#/definitions/Movie', - ], - ], + '$ref' => '#/definitions/Movie', ], ], ], @@ -263,10 +228,9 @@ public function testClassArrayProperty(): void { $schema = new Schema(); $schema->addProperty( - 'movie', - new Schema\Property( - type: Schema\Type::Array, - options: [Movie::class], + name: 'movie', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Array, collectionTypes: [new PropertyType(type: Movie::class)])], title: 'Some movie', required: false, ), @@ -274,6 +238,7 @@ public function testClassArrayProperty(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'movie' => [ 'title' => 'Some movie', @@ -292,10 +257,9 @@ public function testArrayOfClassesAndStrings(): void { $schema = new Schema(); $schema->addProperty( - 'movie', - new Schema\Property( - type: Schema\Type::Array, - options: [Movie::class, Schema\Type::String], + name: 'movie', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Array, collectionTypes: [new PropertyType(type: Movie::class), new PropertyType(type: Schema\Type::String)])], title: 'Some movie', required: false, ), @@ -303,6 +267,7 @@ public function testArrayOfClassesAndStrings(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'movie' => [ 'title' => 'Some movie', @@ -328,10 +293,9 @@ public function testNestedDefinition(): void { $schema = new Schema(); $schema->addProperty( - 'movie', - new Schema\Property( - type: Schema\Type::Array, - options: [Movie::class, Schema\Type::String], + name: 'movie', + property: new Schema\Property( + types: [new PropertyType(type: Schema\Type::Array, collectionTypes: [new PropertyType(type: Movie::class), new PropertyType(type: Schema\Type::String)])], title: 'Some movie', required: false, ), @@ -341,7 +305,7 @@ public function testNestedDefinition(): void type: Movie::class, properties: [ 'title' => new Schema\Property( - type: Schema\Type::String, + types: [new PropertyType(type: Schema\Type::String)], title: 'Title', description: 'Title of the movie', required: true, @@ -353,6 +317,7 @@ public function testNestedDefinition(): void $this->assertEquals( [ + 'type' => 'object', 'properties' => [ 'movie' => [ 'title' => 'Some movie',