From 8b3efe5db9f55746c70b18c405f3c50ddf731c23 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 16 Jan 2025 22:35:58 +0000 Subject: [PATCH] add class converter and more tests --- .gitignore | 1 + README.md | 58 ++++++- composer.json | 5 +- phpunit.dist.xml | 5 + src/Contracts/Converter.php | 15 ++ src/Contracts/Schema.php | 14 -- src/Converters/ClassConverter.php | 128 ++++++++++++++ src/Converters/ClosureConverter.php | 69 ++------ .../Concerns/InteractsWithTypes.php | 58 +++++++ src/SchemaFactory.php | 14 ++ src/Support/DocParser.php | 30 +++- src/Types/AbstractSchema.php | 10 -- src/Types/ArraySchema.php | 24 --- src/Types/Concerns/HasDescription.php | 8 - src/Types/Concerns/HasMetadata.php | 29 +--- src/Types/Concerns/HasProperties.php | 34 ---- src/Types/StringSchema.php | 4 +- tests/ArchitectureTest.php | 8 + tests/Pest.php | 2 +- tests/Unit/Converters/ClassConverterTest.php | 158 ++++++++++++++++++ .../Unit/Converters/ClosureConverterTest.php | 15 ++ tests/Unit/SchemaFactoryTest.php | 9 + tests/Unit/Support/DocParserTest.php | 22 +++ tests/Unit/Targets/ArraySchemaTest.php | 48 ++++++ tests/Unit/Targets/ObjectSchemaTest.php | 2 + tests/Unit/Targets/StringSchemaTest.php | 32 ++++ 26 files changed, 626 insertions(+), 176 deletions(-) create mode 100644 src/Contracts/Converter.php create mode 100644 src/Converters/ClassConverter.php create mode 100644 src/Converters/Concerns/InteractsWithTypes.php create mode 100644 tests/ArchitectureTest.php create mode 100644 tests/Unit/Converters/ClassConverterTest.php diff --git a/.gitignore b/.gitignore index ac4ef68..fe4094c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ composer.lock .env .DS_Store .phpstan-cache +coverage diff --git a/README.md b/README.md index c99a7dc..e7a707b 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ $schema ## Converting to JSON Schema -This uses reflection to infer the schema from the closure parameters and docblocks. +This uses reflection to infer the schema from the parameters and docblocks. ### From a Closure @@ -500,6 +500,62 @@ $schema->toJson(); } ``` +### From a Class + +```php +use Cortex\JsonSchema\SchemaFactory; + +class User +{ + /** + * @var string The name of the user + */ + public string $name; + + /** + * @var ?int The age of the user + */ + public ?int $age = null; + + /** + * @var float The height of the user in meters + */ + public float $height = 1.7; +} + +$schema = SchemaFactory::fromClass(User::class); + +$schema->toJson(); +``` + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "User", + "properties": { + "name": { + "type": "string", + "description": "The name of the user" + }, + "age": { + "type": [ + "integer", + "null" + ], + "description": "The age of the user", + "default": null + }, + "height": { + "type": "number", + "description": "The height of the user in meters", + "default": 1.7 + } + }, + "required": ["name"] +} +``` + ## Credits - [Sean Tymon](https://github.com/tymondesigns) diff --git a/composer.json b/composer.json index bc388f0..943c9a9 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ }, "require-dev": { "pestphp/pest": "^3.0", + "pestphp/pest-plugin-type-coverage": "^3.2", "phpstan/phpstan": "^2.0", "rector/rector": "^2.0", "symplify/easy-coding-standard": "^12.5" @@ -42,6 +43,7 @@ "ecs": "ecs check --fix", "rector": "rector process", "stan": "phpstan analyse", + "type-coverage": "pest --type-coverage --min=100", "format": [ "@rector", "@ecs" @@ -49,7 +51,8 @@ "check": [ "@format", "@test", - "@stan" + "@stan", + "@type-coverage" ] }, "config": { diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 0c12bb9..8a4d3b6 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -9,6 +9,11 @@ ./tests + + + + + ./src diff --git a/src/Contracts/Converter.php b/src/Contracts/Converter.php new file mode 100644 index 0000000..8f16dd9 --- /dev/null +++ b/src/Contracts/Converter.php @@ -0,0 +1,15 @@ + - */ - public function getType(): SchemaType|array; - /** * Determine if the schema is required */ diff --git a/src/Converters/ClassConverter.php b/src/Converters/ClassConverter.php new file mode 100644 index 0000000..f3154dc --- /dev/null +++ b/src/Converters/ClassConverter.php @@ -0,0 +1,128 @@ + + */ + protected ReflectionClass $reflection; + + /** + * @param object|class-string $class + */ + public function __construct( + protected object|string $class, + protected bool $publicOnly = true, + ) { + $this->reflection = new ReflectionClass($this->class); + } + + public function convert(): ObjectSchema + { + $schema = new ObjectSchema(); + + // Get the description from the doc parser + $description = $this->getDocParser($this->reflection)?->description() ?? null; + + // Add the description to the schema if it exists + if ($description !== null) { + $schema->description($description); + } + + $properties = $this->reflection->getProperties( + $this->publicOnly ? ReflectionProperty::IS_PUBLIC : null, + ); + + // Add the properties to the object schema + foreach ($properties as $property) { + $schema->properties(self::getSchemaFromReflectionProperty($property)); + } + + return $schema; + } + + /** + * Create a schema from a given type. + */ + protected function getSchemaFromReflectionProperty( + ReflectionProperty $property, + ): Schema { + $type = $property->getType(); + + // @phpstan-ignore argument.type + $schema = self::getSchemaFromReflectionType($type); + + $schema->title($property->getName()); + + // Add the description to the schema if it exists + $variable = $this->getDocParser($property)?->variable(); + + // Add the description to the schema if it exists + if (isset($variable['description']) && $variable['description'] !== '') { + $schema->description($variable['description']); + } + + if ($type === null || $type->allowsNull()) { + $schema->nullable(); + } + + if ($property->hasDefaultValue()) { + $defaultValue = $property->getDefaultValue(); + + // If the default value is a backed enum, use its value + if ($defaultValue instanceof BackedEnum) { + $defaultValue = $defaultValue->value; + } + + $schema->default($defaultValue); + } else { + $schema->required(); + } + + // If it's an enum, add the possible values + if ($type instanceof ReflectionNamedType) { + $typeName = $type->getName(); + + if (enum_exists($typeName)) { + $reflection = new ReflectionEnum($typeName); + + if ($reflection->isBacked()) { + /** @var non-empty-array */ + $cases = $typeName::cases(); + $schema->enum(array_map(fn(BackedEnum $case): int|string => $case->value, $cases)); + } + } + } + + return $schema; + } + + /** + * @param ReflectionProperty|ReflectionClass $reflection + */ + protected function getDocParser(ReflectionProperty|ReflectionClass $reflection): ?DocParser + { + if ($docComment = $reflection->getDocComment()) { + return new DocParser($docComment); + } + + return null; + } +} diff --git a/src/Converters/ClosureConverter.php b/src/Converters/ClosureConverter.php index 4652cff..2ff5b7a 100644 --- a/src/Converters/ClosureConverter.php +++ b/src/Converters/ClosureConverter.php @@ -5,21 +5,21 @@ namespace Cortex\JsonSchema\Converters; use Closure; +use BackedEnum; use ReflectionEnum; use ReflectionFunction; use ReflectionNamedType; use ReflectionParameter; -use ReflectionUnionType; -use ReflectionIntersectionType; use Cortex\JsonSchema\Contracts\Schema; -use Cortex\JsonSchema\Enums\SchemaType; use Cortex\JsonSchema\Support\DocParser; -use Cortex\JsonSchema\Types\UnionSchema; use Cortex\JsonSchema\Types\ObjectSchema; -use Cortex\JsonSchema\Exceptions\SchemaException; +use Cortex\JsonSchema\Contracts\Converter; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithTypes; -class ClosureConverter +class ClosureConverter implements Converter { + use InteractsWithTypes; + protected ReflectionFunction $reflection; public function __construct( @@ -56,7 +56,7 @@ public function convert(): ObjectSchema * * @param array, description: string|null}> $docParams */ - protected static function getSchemaFromReflectionParameter( + protected function getSchemaFromReflectionParameter( ReflectionParameter $parameter, array $docParams = [], ): Schema { @@ -68,7 +68,7 @@ protected static function getSchemaFromReflectionParameter( $schema->title($parameter->getName()); // Add the description to the schema if it exists - $param = array_filter($docParams, fn($param): bool => $param['name'] === $parameter->getName()); + $param = array_filter($docParams, static fn(array $param): bool => $param['name'] === $parameter->getName()); $description = $param[0]['description'] ?? null; if ($description !== null) { @@ -80,7 +80,14 @@ protected static function getSchemaFromReflectionParameter( } if ($parameter->isDefaultValueAvailable() && ! $parameter->isDefaultValueConstant()) { - $schema->default($parameter->getDefaultValue()); + $defaultValue = $parameter->getDefaultValue(); + + // If the default value is a backed enum, use its value + if ($defaultValue instanceof BackedEnum) { + $defaultValue = $defaultValue->value; + } + + $schema->default($defaultValue); } if (! $parameter->isOptional()) { @@ -97,7 +104,7 @@ protected static function getSchemaFromReflectionParameter( if ($reflection->isBacked()) { /** @var non-empty-array */ $cases = $typeName::cases(); - $schema->enum(array_map(fn($case): int|string => $case->value, $cases)); + $schema->enum(array_map(fn(BackedEnum $case): int|string => $case->value, $cases)); } } } @@ -105,48 +112,6 @@ protected static function getSchemaFromReflectionParameter( return $schema; } - /** - * Resolve the schema instance from the given reflection type. - */ - protected static function getSchemaFromReflectionType( - ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type, - ): Schema { - $schemaTypes = match (true) { - $type instanceof ReflectionUnionType, $type instanceof ReflectionIntersectionType => array_map( - // @phpstan-ignore argument.type - fn(ReflectionNamedType $t): SchemaType => self::resolveSchemaType($t), - $type->getTypes(), - ), - // If the parameter is not typed or explicitly typed as mixed, we use all schema types - // TODO: use phpstan parser to get the type also - in_array($type?->getName(), ['mixed', null], true) => SchemaType::cases(), - default => [self::resolveSchemaType($type)], - }; - - return count($schemaTypes) === 1 - ? $schemaTypes[0]->instance() - : new UnionSchema($schemaTypes); - } - - /** - * Resolve the schema type from the given reflection type. - */ - protected static function resolveSchemaType(ReflectionNamedType $type): SchemaType - { - $typeName = $type->getName(); - - if (enum_exists($typeName)) { - $reflection = new ReflectionEnum($typeName); - $typeName = $reflection->getBackingType()?->getName(); - - if ($typeName === null) { - throw new SchemaException('Enum type has no backing type: ' . $typeName); - } - } - - return SchemaType::fromScalar($typeName); - } - protected function getDocParser(): ?DocParser { if ($docComment = $this->reflection->getDocComment()) { diff --git a/src/Converters/Concerns/InteractsWithTypes.php b/src/Converters/Concerns/InteractsWithTypes.php new file mode 100644 index 0000000..3b209be --- /dev/null +++ b/src/Converters/Concerns/InteractsWithTypes.php @@ -0,0 +1,58 @@ + array_map( + // @phpstan-ignore argument.type + fn(ReflectionNamedType $t): SchemaType => self::resolveSchemaType($t), + $type->getTypes(), + ), + // If the parameter is not typed or explicitly typed as mixed, we use all schema types + in_array($type?->getName(), ['mixed', null], true) => SchemaType::cases(), + default => [self::resolveSchemaType($type)], + }; + + return count($schemaTypes) === 1 + ? $schemaTypes[0]->instance() + : new UnionSchema($schemaTypes); + } + + /** + * Resolve the schema type from the given reflection type. + */ + protected static function resolveSchemaType(ReflectionNamedType $type): SchemaType + { + $typeName = $type->getName(); + + if (enum_exists($typeName)) { + $reflection = new ReflectionEnum($typeName); + $typeName = $reflection->getBackingType()?->getName(); + + if ($typeName === null) { + throw new SchemaException('Enum type has no backing type: ' . $reflection->getName()); + } + } + + return SchemaType::fromScalar($typeName); + } +} diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 993e55e..bdbbfc7 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -14,6 +14,7 @@ use Cortex\JsonSchema\Types\StringSchema; use Cortex\JsonSchema\Types\BooleanSchema; use Cortex\JsonSchema\Types\IntegerSchema; +use Cortex\JsonSchema\Converters\ClassConverter; use Cortex\JsonSchema\Converters\ClosureConverter; class SchemaFactory @@ -66,8 +67,21 @@ public static function mixed(?string $title = null): UnionSchema return new UnionSchema(SchemaType::cases(), $title); } + /** + * Create a schema from a given closure. + */ public static function fromClosure(Closure $closure): ObjectSchema { return (new ClosureConverter($closure))->convert(); } + + /** + * Create a schema from a given class. + * + * @param object|class-string $class + */ + public static function fromClass(object|string $class, bool $publicOnly = true): ObjectSchema + { + return (new ClassConverter($class, $publicOnly))->convert(); + } } diff --git a/src/Support/DocParser.php b/src/Support/DocParser.php index eb33fa2..ff0a624 100644 --- a/src/Support/DocParser.php +++ b/src/Support/DocParser.php @@ -16,6 +16,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TypelessParamTagValueNode; @@ -46,7 +47,7 @@ public function params(): array return array_map( static fn(ParamTagValueNode|TypelessParamTagValueNode $param): array => [ 'name' => ltrim($param->parameterName, '$'), - 'types' => self::mapParamToTypes($param), + 'types' => self::mapValueNodeToTypes($param), 'description' => empty($param->description) ? null : $param->description, ], array_merge( @@ -57,12 +58,33 @@ public function params(): array } /** - * Map the parameter to its types. + * Get the variable from the docblock. * - * @return array + * @return array{name: string, types: array, description: string|null}|array{} */ - protected static function mapParamToTypes(ParamTagValueNode|TypelessParamTagValueNode $param): array + public function variable(): array { + $vars = array_map( + static fn(VarTagValueNode $var): array => [ + 'name' => ltrim($var->variableName, '$'), + 'types' => self::mapValueNodeToTypes($var), + 'description' => $var->description === '' ? null : $var->description, + ], + $this->parse()->getVarTagValues(), + ); + + // There should only be one variable in the docblock. + return $vars[0] ?? []; + } + + /** + * Map the value node to its types. + * + * @return array + */ + protected static function mapValueNodeToTypes( + ParamTagValueNode|TypelessParamTagValueNode|VarTagValueNode $param, + ): array { if ($param instanceof TypelessParamTagValueNode) { return []; } diff --git a/src/Types/AbstractSchema.php b/src/Types/AbstractSchema.php index ea0fe0a..67b2437 100644 --- a/src/Types/AbstractSchema.php +++ b/src/Types/AbstractSchema.php @@ -42,16 +42,6 @@ public function __construct( $this->title = $title; } - /** - * Get the type or types. - * - * @return \Cortex\JsonSchema\Enums\SchemaType|array - */ - public function getType(): SchemaType|array - { - return $this->type; - } - /** * Add null type to schema. */ diff --git a/src/Types/ArraySchema.php b/src/Types/ArraySchema.php index 4efcd0d..54de85e 100644 --- a/src/Types/ArraySchema.php +++ b/src/Types/ArraySchema.php @@ -35,14 +35,6 @@ public function contains(Schema $schema): static return $this; } - /** - * Get the contains schema - */ - public function getContains(): ?Schema - { - return $this->contains; - } - /** * Set the minimum number of items that must match the contains schema * @@ -63,14 +55,6 @@ public function minContains(int $min): static return $this; } - /** - * Get the minimum number of items that must match the contains schema - */ - public function getMinContains(): ?int - { - return $this->minContains; - } - /** * Set the maximum number of items that can match the contains schema * @@ -91,14 +75,6 @@ public function maxContains(int $max): static return $this; } - /** - * Get the maximum number of items that can match the contains schema - */ - public function getMaxContains(): ?int - { - return $this->maxContains; - } - /** * Convert the schema to an array. * diff --git a/src/Types/Concerns/HasDescription.php b/src/Types/Concerns/HasDescription.php index 8fb0425..fe4dfe7 100644 --- a/src/Types/Concerns/HasDescription.php +++ b/src/Types/Concerns/HasDescription.php @@ -19,14 +19,6 @@ public function description(string $description): static return $this; } - /** - * Get the description - */ - public function getDescription(): ?string - { - return $this->description; - } - /** * Add description field to schema array * diff --git a/src/Types/Concerns/HasMetadata.php b/src/Types/Concerns/HasMetadata.php index 650d1be..6316812 100644 --- a/src/Types/Concerns/HasMetadata.php +++ b/src/Types/Concerns/HasMetadata.php @@ -9,6 +9,8 @@ trait HasMetadata { protected mixed $default = null; + protected bool $hasDefault = false; + protected bool $deprecated = false; protected ?string $comment = null; @@ -24,18 +26,11 @@ trait HasMetadata public function default(mixed $value): static { $this->default = $value; + $this->hasDefault = true; return $this; } - /** - * Get the default value - */ - public function getDefault(): mixed - { - return $this->default; - } - /** * Mark the schema as deprecated */ @@ -46,14 +41,6 @@ public function deprecated(bool $deprecated = true): static return $this; } - /** - * Check if the schema is deprecated - */ - public function isDeprecated(): bool - { - return $this->deprecated; - } - /** * Set a comment for the schema */ @@ -64,14 +51,6 @@ public function comment(string $comment): static return $this; } - /** - * Get the schema comment - */ - public function getComment(): ?string - { - return $this->comment; - } - /** * Add examples to the schema * @@ -93,7 +72,7 @@ public function examples(array $examples): static */ protected function addMetadataToSchema(array $schema): array { - if ($this->default !== null) { + if ($this->hasDefault) { $schema['default'] = $this->default; } diff --git a/src/Types/Concerns/HasProperties.php b/src/Types/Concerns/HasProperties.php index 276296e..2d38d57 100644 --- a/src/Types/Concerns/HasProperties.php +++ b/src/Types/Concerns/HasProperties.php @@ -62,16 +62,6 @@ public function additionalProperties(bool $allowed): static return $this; } - /** - * Get the property keys - * - * @return array - */ - public function getPropertyKeys(): array - { - return array_keys($this->properties); - } - /** * Set the minimum number of properties * @@ -92,14 +82,6 @@ public function minProperties(int $min): static return $this; } - /** - * Get the minimum number of properties - */ - public function getMinProperties(): ?int - { - return $this->minProperties; - } - /** * Set the maximum number of properties * @@ -120,14 +102,6 @@ public function maxProperties(int $max): static return $this; } - /** - * Get the maximum number of properties - */ - public function getMaxProperties(): ?int - { - return $this->maxProperties; - } - /** * Set the schema for property names */ @@ -138,14 +112,6 @@ public function propertyNames(Schema $schema): static return $this; } - /** - * Get the property names schema - */ - public function getPropertyNames(): ?Schema - { - return $this->propertyNames; - } - /** * Add properties to schema array * diff --git a/src/Types/StringSchema.php b/src/Types/StringSchema.php index eb250ee..c1387b8 100644 --- a/src/Types/StringSchema.php +++ b/src/Types/StringSchema.php @@ -27,7 +27,7 @@ public function __construct(?string $title = null) public function minLength(int $length): static { if ($length < 0) { - throw new SchemaException('Minimum length must be non-negative'); + throw new SchemaException('Minimum length must be greater than or equal to 0'); } $this->minLength = $length; @@ -43,7 +43,7 @@ public function minLength(int $length): static public function maxLength(int $length): static { if ($length < 0) { - throw new SchemaException('Maximum length must be non-negative'); + throw new SchemaException('Maximum length must be greater than or equal to 0'); } if ($this->minLength !== null && $length < $this->minLength) { diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php new file mode 100644 index 0000000..c0fb154 --- /dev/null +++ b/tests/ArchitectureTest.php @@ -0,0 +1,8 @@ +preset()->php(); +arch()->preset()->security(); diff --git a/tests/Pest.php b/tests/Pest.php index 42e38fa..dc05243 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,6 @@ declare(strict_types=1); -use Cortex\JsonSchema\Tests\TestCase; +namespace Cortex\JsonSchema\Tests; uses(TestCase::class)->in('Unit'); diff --git a/tests/Unit/Converters/ClassConverterTest.php b/tests/Unit/Converters/ClassConverterTest.php new file mode 100644 index 0000000..2ecb802 --- /dev/null +++ b/tests/Unit/Converters/ClassConverterTest.php @@ -0,0 +1,158 @@ +convert(); + + expect($schema)->toBeInstanceOf(ObjectSchema::class); + expect($schema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => [ + 'integer', + 'null', + ], + 'default' => null, + ], + 'height' => [ + 'type' => 'number', + 'default' => 1.7, + ], + ], + 'required' => [ + 'name', + ], + ]); +}); + +it('can create a schema from a class with docblocks', function (): void { + $schema = (new ClassConverter(new class () { + /** + * @var string The name of the user + */ + public string $name; + + /** + * @var ?int The age of the user + */ + public ?int $age = null; + + /** + * @var float The height of the user in meters + */ + public float $height = 1.7; + }))->convert(); + + expect($schema)->toBeInstanceOf(ObjectSchema::class); + expect($schema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user', + ], + 'age' => [ + 'type' => [ + 'integer', + 'null', + ], + 'description' => 'The age of the user', + 'default' => null, + ], + 'height' => [ + 'type' => 'number', + 'description' => 'The height of the user in meters', + 'default' => 1.7, + ], + ], + 'required' => [ + 'name', + ], + ]); +}); + +it('can create a schema from a class with constructor property promotion', function (): void { + /** This is the description of the class */ + $class = new class ('John Doe') { + /** + * This is the description of the constructor + */ + public function __construct( + public string $name, + public int $age = 20, + ) {} + }; + + $schema = (new ClassConverter($class))->convert(); + + expect($schema)->toBeInstanceOf(ObjectSchema::class); + expect($schema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'description' => 'This is the description of the class', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => 'integer', + ], + ], + 'required' => [ + 'name', + 'age', + ], + ]); +}); + +it('can create a schema from a class with an enum', function (): void { + enum UserStatus: string + { + case Active = 'active'; + case Inactive = 'inactive'; + case Pending = 'pending'; + } + + $schema = (new ClassConverter(new class () { + public string $name; + + public UserStatus $status = UserStatus::Pending; + }))->convert(); + + expect($schema)->toBeInstanceOf(ObjectSchema::class); + expect($schema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'status' => [ + 'type' => 'string', + 'enum' => ['active', 'inactive', 'pending'], + 'default' => 'pending', + ], + ], + 'required' => [ + 'name', + ], + ]); +}); diff --git a/tests/Unit/Converters/ClosureConverterTest.php b/tests/Unit/Converters/ClosureConverterTest.php index c6655b9..0203949 100644 --- a/tests/Unit/Converters/ClosureConverterTest.php +++ b/tests/Unit/Converters/ClosureConverterTest.php @@ -5,6 +5,7 @@ namespace Cortex\JsonSchema\Tests\Unit\Converters; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\JsonSchema\Exceptions\SchemaException; use Cortex\JsonSchema\Converters\ClosureConverter; it('can create a schema from a closure', function (): void { @@ -27,6 +28,7 @@ 'integer', 'null', ], + 'default' => null, ], ], 'required' => [ @@ -108,6 +110,18 @@ enum Status: int ]); }); +it('throws an exception if the enum is not a backed enum', function (): void { + enum StatusNoBackingType + { + case Draft; + case Published; + case Archived; + } + + $closure = function (StatusNoBackingType $status): void {}; + (new ClosureConverter($closure))->convert(); +})->throws(SchemaException::class, 'Enum type has no backing type: Cortex\JsonSchema\Tests\Unit\Converters\StatusNoBackingType'); + it('can create a schema from a closure with a union type', function (): void { $closure = function (int|string $foo): void {}; $schema = (new ClosureConverter($closure))->convert(); @@ -266,6 +280,7 @@ enum Status: int 'string', 'null', ], + 'default' => null, ], 'tags' => [ 'type' => 'array', diff --git a/tests/Unit/SchemaFactoryTest.php b/tests/Unit/SchemaFactoryTest.php index 2ab8070..727b215 100644 --- a/tests/Unit/SchemaFactoryTest.php +++ b/tests/Unit/SchemaFactoryTest.php @@ -4,8 +4,10 @@ namespace Cortex\JsonSchema\Tests\Unit; +use Cortex\JsonSchema\Enums\SchemaType; use Cortex\JsonSchema\Types\NullSchema; use Cortex\JsonSchema\Types\ArraySchema; +use Cortex\JsonSchema\Types\UnionSchema; use Cortex\JsonSchema\Types\NumberSchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Types\StringSchema; @@ -34,6 +36,12 @@ // Test string schema creation expect(Schema::string('name'))->toBeInstanceOf(StringSchema::class); + + // Test union schema creation + expect(Schema::union([SchemaType::String, SchemaType::Integer]))->toBeInstanceOf(UnionSchema::class); + + // Test mixed schema creation + expect(Schema::mixed())->toBeInstanceOf(UnionSchema::class); }); it('can create schemas with default metadata', function (): void { @@ -71,6 +79,7 @@ 'integer', 'null', ], + 'default' => null, ], ], 'required' => [ diff --git a/tests/Unit/Support/DocParserTest.php b/tests/Unit/Support/DocParserTest.php index 8968cf7..22ff5e4 100644 --- a/tests/Unit/Support/DocParserTest.php +++ b/tests/Unit/Support/DocParserTest.php @@ -73,3 +73,25 @@ ], ]); }); + +it('can parse variables', function (): void { + $docblock = '/** @var string $nickname The nickname of the user */'; + $parser = new DocParser($docblock); + + expect($parser->variable())->toBe([ + 'name' => 'nickname', + 'types' => ['string'], + 'description' => 'The nickname of the user', + ]); +}); + +it('can parse variables with multiple types', function (): void { + $docblock = '/** @var string|int $nickname The nickname of the user */'; + $parser = new DocParser($docblock); + + expect($parser->variable())->toBe([ + 'name' => 'nickname', + 'types' => ['string', 'int'], + 'description' => 'The nickname of the user', + ]); +}); diff --git a/tests/Unit/Targets/ArraySchemaTest.php b/tests/Unit/Targets/ArraySchemaTest.php index 7f489e5..90476ce 100644 --- a/tests/Unit/Targets/ArraySchemaTest.php +++ b/tests/Unit/Targets/ArraySchemaTest.php @@ -75,3 +75,51 @@ 'All array items must match schema', ); }); + +it('can validate array contains', function (): void { + // First test just contains without min/max + $basicSchema = Schema::array('numbers') + ->description('List of numbers') + // Must contain at least one number between 10 and 20 + ->contains(Schema::number()->minimum(10)->maximum(20)); + + // Test basic contains validation + expect(fn() => $basicSchema->validate([15, 5, 6]))->not->toThrow(SchemaException::class); + expect(fn() => $basicSchema->validate([1, 2, 3]))->toThrow( + SchemaException::class, + 'At least one array item must match schema', + ); + + // Now test with minContains and maxContains + $schema = Schema::array('numbers') + ->description('List of numbers') + ->contains( + Schema::number() + ->minimum(10) + ->maximum(20), + ) + ->minContains(2) + ->maxContains(3); + + $schemaArray = $schema->toArray(); + + expect($schemaArray)->toHaveKey('$schema', 'http://json-schema.org/draft-07/schema#'); + expect($schemaArray)->toHaveKey('type', 'array'); + expect($schemaArray)->toHaveKey('title', 'numbers'); + expect($schemaArray)->toHaveKey('description', 'List of numbers'); + expect($schemaArray)->toHaveKey('contains.type', 'number'); + expect($schemaArray)->toHaveKey('contains.minimum', 10); + expect($schemaArray)->toHaveKey('contains.maximum', 20); + expect($schemaArray)->toHaveKey('minContains', 2); + expect($schemaArray)->toHaveKey('maxContains', 3); + + // Valid cases - arrays with 2-3 numbers between 10-20 + expect(fn() => $schema->validate([15, 12, 5]))->not->toThrow(SchemaException::class); + expect(fn() => $schema->validate([15, 12, 18, 5]))->not->toThrow(SchemaException::class); + + // Test no matching items + expect(fn() => $schema->validate([1, 2, 3]))->toThrow( + SchemaException::class, + 'At least one array item must match schema', + ); +}); diff --git a/tests/Unit/Targets/ObjectSchemaTest.php b/tests/Unit/Targets/ObjectSchemaTest.php index 887194c..e68882d 100644 --- a/tests/Unit/Targets/ObjectSchemaTest.php +++ b/tests/Unit/Targets/ObjectSchemaTest.php @@ -5,6 +5,7 @@ namespace Cortex\JsonSchema\Tests\Unit; use Cortex\JsonSchema\Enums\SchemaFormat; +use Opis\JsonSchema\Errors\ValidationError; use Cortex\JsonSchema\SchemaFactory as Schema; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -97,6 +98,7 @@ "The data must match the 'email' format", ], ]); + expect($e->getError())->toBeInstanceOf(ValidationError::class); throw $e; } diff --git a/tests/Unit/Targets/StringSchemaTest.php b/tests/Unit/Targets/StringSchemaTest.php index 39d6487..5026a2b 100644 --- a/tests/Unit/Targets/StringSchemaTest.php +++ b/tests/Unit/Targets/StringSchemaTest.php @@ -37,6 +37,22 @@ expect(fn() => $schema->validate('valid-username'))->not->toThrow(SchemaException::class); }); +it('throws an exception if the minLength is greater than the maxLength', function (): void { + Schema::string('username') + ->minLength(51) + ->maxLength(50); +})->throws(SchemaException::class, 'Maximum length must be greater than or equal to minimum length'); + +it('throws an exception if the minLength is less than 0', function (): void { + Schema::string('username') + ->minLength(-1); +})->throws(SchemaException::class, 'Minimum length must be greater than or equal to 0'); + +it('throws an exception if the maxLength is less than 0', function (): void { + Schema::string('username') + ->maxLength(-1); +})->throws(SchemaException::class, 'Maximum length must be greater than or equal to 0'); + it('can create a string schema with pattern validation', function (): void { $schema = Schema::string('password') ->description('User password') @@ -182,3 +198,19 @@ expect(fn() => $schema->validate('high'))->not->toThrow(SchemaException::class); expect(fn() => $schema->validate(null))->not->toThrow(SchemaException::class); }); + +it('can mark a string schema as deprecated', function (): void { + $schema = Schema::string('foo') + ->comment("Don't use this") + ->deprecated(); + + expect($schema->toArray()) + ->toHaveKey('deprecated', true) + ->toHaveKey('$comment', "Don't use this"); +}); + +it('can create a string schema with examples', function (): void { + $schema = Schema::string('foo')->examples(['foo', 'bar']); + + expect($schema->toArray())->toHaveKey('examples', ['foo', 'bar']); +});