diff --git a/README.md b/README.md index d5a5a7a..2d33ec6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 📝 **Multi-Version Support** - Support for JSON Schema Draft-06, Draft-07, Draft 2019-09, and Draft 2020-12 - ✅ **Validation** - Validate data against schemas with detailed error messages - 🤝 **Conditional Schemas** - Support for if/then/else, allOf, anyOf, and not conditions -- 🔄 **Reflection** - Generate schemas from PHP Classes, Enums and Closures +- 🔄 **Reflection** - Generate schemas from PHP classes, enums, and closures — including docblock array generics and constructor property promotion - 💪 **Type Safety** - Built with PHP 8.3+ features and strict typing - 🔍 **Version-Aware Features** - Automatic validation of version-specific features with helpful error messages diff --git a/docs/json-schema/code-generation/from-classes.mdx b/docs/json-schema/code-generation/from-classes.mdx index 828bb03..6cf8815 100644 --- a/docs/json-schema/code-generation/from-classes.mdx +++ b/docs/json-schema/code-generation/from-classes.mdx @@ -4,7 +4,7 @@ description: 'Generate JSON schemas automatically from PHP class definitions' icon: 'code' --- -Generate JSON schemas automatically from PHP classes using reflection and docblock analysis. This feature extracts property types, validation rules, and documentation from your existing PHP classes. +Generate JSON schemas automatically from PHP classes using reflection and docblock analysis. This feature extracts property types, defaults, documentation, and array element types from your existing PHP classes. ## Basic Class Schema Generation @@ -113,7 +113,7 @@ class Product public bool $in_stock = true; /** - * @var array Product tags + * @var string[] Product tags */ public array $tags = []; } @@ -155,6 +155,9 @@ $schema = Schema::fromClass(Product::class); "tags": { "type": "array", "description": "Product tags", + "items": { + "type": "string" + }, "default": [] } }, @@ -188,12 +191,65 @@ class ModernUser // Generate with Draft 2019-09 for deprecated support $schema = Schema::fromClass( ModernUser::class, - version: SchemaVersion::Draft_2019_09 + schemaVersion: SchemaVersion::Draft_2019_09 ); // The generated schema will include "deprecated": true for old_email ``` +## Constructor Property Promotion + +Promoted constructor properties are supported. Descriptions and array element types are read from the constructor's `@param` tags when the property itself has no docblock: + +```php +/** + * User data transfer object + * + * @param string $name The user's full name + * @param int $age The user's age in years + */ +class UserDto +{ + public function __construct( + public string $name, + public int $age = 18, + ) {} +} + +$schema = Schema::fromClass(UserDto::class); +// name: required string with @param description +// age: optional integer with default 18 +``` + +An explicit `@var` tag on a promoted property takes precedence over the matching `@param` description. + +## Array Item Types + +When a property is typed as `array`, the converter reads element types from docblock generics and adds an `items` schema. Supported syntax includes: + +- `string[]` and `(int|string)[]` +- `array`, `array`, and `array` (value type is used for key-value maps) +- `list`, `non-empty-array`, and similar list/array generics + +```php +class Article +{ + /** @var string[] Article tags */ + public array $tags; + + /** @var array Flexible identifiers */ + public array $ids; +} + +$schema = Schema::fromClass(Article::class); +// tags: { "type": "array", "items": { "type": "string" } } +// ids: { "type": "array", "items": { "type": ["integer", "string"] } } +``` + + +Array item types are limited to JSON Schema scalar types (`string`, `integer`, `number`, `boolean`, `null`, `object`, `array`) and unions of those scalars. Class names (e.g. `DateTime[]`), `mixed`, and nested generics (e.g. `array>`) are skipped silently, leaving a plain `array` without `items`. + + ## Complex Property Types Handle complex property types and collections: @@ -210,7 +266,7 @@ class Order public string $id; /** - * @var array List of order items + * @var array List of order item SKUs */ public array $items; @@ -225,26 +281,51 @@ class Order public ?object $billing_address = null; /** - * @var array Additional notes + * @var string[] Additional notes */ public array $notes = []; /** - * @var array Metadata key-value pairs + * @var array Metadata key-value pairs */ public array $metadata = []; } $schema = Schema::fromClass(Order::class); -// Custom class types (like Address) are treated as generic 'object' type -// Generic array syntax (e.g., array) in docblocks is not parsed -// Arrays are treated as generic arrays without item type information +// items, notes, and metadata include string item schemas +// Custom class types (like Address) require ignoreUnknownTypes or separate schema generation ``` -Custom class types (non-built-in PHP types) are converted to generic `object` schemas. The converter does not recursively analyze nested classes or parse generic array syntax from docblocks. To create schemas for nested objects, generate them separately and combine them using the fluent API. +Custom class types (non-built-in PHP types) are not mapped to JSON Schema types by default and will throw an `UnknownTypeException`. Use `ignoreUnknownTypes: true` to skip unmappable properties, or generate nested object schemas separately and combine them using the fluent API. +## Handling Unknown Types + +By default, properties typed as custom classes (e.g. `DateTime`, `Address`) cause conversion to fail. Pass `ignoreUnknownTypes: true` to omit those properties from the generated schema: + +```php +class Event +{ + public string $title; + + public DateTime $starts_at; + + public ?DateTime $ends_at = null; +} + +// Throws UnknownTypeException for DateTime properties +$schema = Schema::fromClass(Event::class); + +// Skips DateTime properties, includes title only +$schema = Schema::fromClass( + Event::class, + ignoreUnknownTypes: true, +); +``` + +This option is also available on `Schema::from()` when passing a class or object instance. + ## Enum Integration Automatically handle backed enums: @@ -363,9 +444,14 @@ class CompleteUser extends BaseEntity } $schema = Schema::fromClass(CompleteUser::class); -// Schema will include properties from base class and traits +// Schema includes properties from the base class and traits +// Static properties are excluded ``` + +Only **public** properties are included by default. Pass `publicOnly: false` to include protected and private properties. Static properties are always excluded. + + ## Validation and Usage Use the generated schema to validate data: @@ -418,13 +504,14 @@ if ($userSchema->isValid($userData)) { The schema generator automatically extracts the following from your docblocks: -- **Description text** - From the docblock summary and description -- **Property types** - From `@var` annotations (combined with native PHP types) -- **Parameter types** - From `@param` annotations -- **Deprecation status** - Using the `@deprecated` tag +- **Description text** - From the class docblock summary and `@var` property descriptions +- **Promoted property descriptions** - From matching constructor `@param` tags when no `@var` is present +- **Property types** - From native PHP type hints (combined with `@var` where present) +- **Array item types** - From generic array syntax in `@var` or `@param` tags (scalar element types only) +- **Deprecation status** - Using the `@deprecated` tag on classes and properties -Validation rules (like minLength, pattern, format) are **not** extracted from docblocks. You need to apply them programmatically using the fluent API after generation, or define them in your actual PHP code using native types and enums. +Validation rules (like minLength, pattern, format) are **not** extracted from docblocks. Apply them programmatically using the fluent API after generation, or rely on native PHP types and backed enums for type validation. ## Best Practices @@ -485,7 +572,7 @@ public string $email; */ public string $username; -// Good: Document complex array types +// Good: Document array element types with generics /** * @var array List of user roles */ @@ -493,7 +580,7 @@ public array $roles; ``` -Validation rules like minLength, pattern, format are not extracted from docblocks. Generic array syntax (e.g., `array`, `array`) in docblocks is not parsed - arrays are treated as generic arrays without item type information. Apply validation rules and array item schemas programmatically using the fluent API after schema generation, or use native PHP types and enums for type validation. +Validation rules like minLength, pattern, and format are not extracted from docblocks. Array item types are inferred from generic syntax for scalar elements only — nested object or array element types (e.g. `array`) are not resolved recursively. Apply additional validation programmatically using the fluent API after schema generation. ## Common Use Cases diff --git a/docs/json-schema/code-generation/from-closures.mdx b/docs/json-schema/code-generation/from-closures.mdx index 3e0aeb4..3b00069 100644 --- a/docs/json-schema/code-generation/from-closures.mdx +++ b/docs/json-schema/code-generation/from-closures.mdx @@ -68,7 +68,7 @@ Handle optional parameters with default values: * @param float $price Product price in USD * @param string $description Product description * @param bool $active Whether the product is active - * @param array $tags Product tags + * @param string[] $tags Product tags */ $createProductClosure = function ( string $name, @@ -111,6 +111,9 @@ $schema = Schema::fromClosure($createProductClosure); "tags": { "type": "array", "description": "Product tags", + "items": { + "type": "string" + }, "default": [] } }, @@ -184,6 +187,41 @@ The generated schema will include nullable types: ``` +## Array Item Types + +When a parameter is typed as `array`, element types from generic docblock syntax are added as an `items` schema: + +```php +/** + * Tag a resource with labels + * + * @param string[] $tags Resource tags + * @param array $ids Associated identifiers + */ +$tagResourceClosure = function (array $tags, array $ids): void {}; + +$schema = Schema::fromClosure($tagResourceClosure); +// tags: { "type": "array", "items": { "type": "string" } } +// ids: { "type": "array", "items": { "type": ["integer", "string"] } } +``` + +Supported syntax matches class property docblocks: `T[]`, `array`, `list`, `array`, and unions of scalar element types. Class names, `mixed`, and nested generics are skipped silently. + +## Handling Unknown Types + +Parameters typed as custom classes throw an `UnknownTypeException` by default. Pass `ignoreUnknownTypes: true` to skip unmappable parameters: + +```php +/** + * @param string $title Event title + * @param DateTime $starts_at Start time + */ +$createEvent = function (string $title, DateTime $starts_at): void {}; + +$schema = Schema::fromClosure($createEvent, ignoreUnknownTypes: true); +// Only includes title +``` + ## Advanced Parameter Documentation Use detailed docblock annotations for validation rules: @@ -261,7 +299,7 @@ Real-world example for API endpoint validation: * @param ?string $email Email filter * @param ?int $age_min Minimum age filter * @param ?int $age_max Maximum age filter - * @param array $roles Role filter + * @param array $roles Role filter * @param int $page Page number for pagination * @param int $per_page Items per page * @param string $sort_by Sort field @@ -319,7 +357,7 @@ $modernApiClosure = function ( // Generate with Draft 2019-09 for deprecated support $modernSchema = Schema::fromClosure( $modernApiClosure, - version: SchemaVersion::Draft_2019_09 + schemaVersion: SchemaVersion::Draft_2019_09 ); // Apply validation rules programmatically after generation @@ -429,7 +467,7 @@ function processData($name, $age, $tags, $date) {} ``` -Validation rules like format, minLength, pattern are not extracted from docblocks. Generic array syntax (e.g., `array`, `array`) in docblocks is not parsed - arrays are treated as generic arrays without item type information. Only parameter types and descriptions are extracted. Apply validation rules and array item schemas programmatically using the fluent API after schema generation. +Validation rules like format, minLength, and pattern are not extracted from docblocks. Array item types are inferred from generic syntax for scalar elements only. Apply additional validation programmatically using the fluent API after schema generation. ### 4. Design Functions for Schema Generation @@ -451,7 +489,7 @@ function handleUserStuff($data, $action, $options = []) {} | `int` | `integer` | Integer numbers | | `float` | `number` | Floating-point numbers | | `bool` | `boolean` | Boolean true/false | -| `array` | `array` | Generic arrays | +| `array` | `array` | Generic arrays; `items` added when docblock specifies element types | | `?string` | `["string", "null"]` | Nullable string | | `mixed` | No type constraint | Accepts any type | | `object` | `object` | Generic object | diff --git a/docs/json-schema/introduction.mdx b/docs/json-schema/introduction.mdx index 0f7dd79..0002e2e 100644 --- a/docs/json-schema/introduction.mdx +++ b/docs/json-schema/introduction.mdx @@ -147,8 +147,8 @@ All schema types support common properties like title, description, examples, de ### Code Generation Generate schemas from existing PHP code: -- **PHP Classes** - Extract schemas from class properties and docblocks -- **Closures** - Generate from function signatures and parameter types +- **PHP Classes** - Extract schemas from class properties, constructor promotion, and docblocks (including array item types) +- **Closures** - Generate from function signatures, parameter types, and docblock generics - **Backed Enums** - Create enum validation schemas - **JSON Import** - Convert existing JSON Schema definitions diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 8a4d3b6..0c12bb9 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -9,11 +9,6 @@ ./tests - - - - - ./src diff --git a/src/Converters/ClassConverter.php b/src/Converters/ClassConverter.php index 27daedf..92cb4f5 100644 --- a/src/Converters/ClassConverter.php +++ b/src/Converters/ClassConverter.php @@ -9,11 +9,16 @@ use ReflectionClass; use ReflectionProperty; use ReflectionNamedType; +use ReflectionParameter; +use Cortex\JsonSchema\Support\NodeData; use Cortex\JsonSchema\Support\DocParser; +use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Contracts\Converter; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Contracts\JsonSchema; +use Cortex\JsonSchema\Support\NodeCollection; +use Cortex\JsonSchema\Exceptions\UnknownTypeException; use Cortex\JsonSchema\Converters\Concerns\InteractsWithTypes; class ClassConverter implements Converter @@ -32,6 +37,7 @@ public function __construct( protected object|string $class, protected bool $publicOnly = true, protected ?SchemaVersion $version = null, + protected bool $ignoreUnknownTypes = false, ) { $this->reflection = new ReflectionClass($this->class); $this->version = $version ?? SchemaVersion::default(); @@ -59,9 +65,26 @@ public function convert(): ObjectSchema $this->publicOnly ? ReflectionProperty::IS_PUBLIC : null, ); + // Constructor `@param` tags document promoted properties, which have no + // docblock of their own. Parse them once so we can resolve descriptions. + $promotedParams = $this->getConstructorParams(); + // Add the properties to the object schema foreach ($properties as $property) { - $objectSchema->properties(self::getSchemaFromReflectionProperty($property)); + // Static properties are not part of the instance state, so skip them. + if ($property->isStatic()) { + continue; + } + + try { + $objectSchema->properties(self::getSchemaFromReflectionProperty($property, $promotedParams)); + } catch (UnknownTypeException $unknownTypeException) { + if ($this->ignoreUnknownTypes) { + continue; + } + + throw $unknownTypeException; + } } return $objectSchema; @@ -69,9 +92,12 @@ public function convert(): ObjectSchema /** * Create a schema from a given type. + * + * @param \Cortex\JsonSchema\Support\NodeCollection|null $nodeCollection */ protected function getSchemaFromReflectionProperty( ReflectionProperty $reflectionProperty, + ?NodeCollection $nodeCollection = null, ): JsonSchema { $type = $reflectionProperty->getType(); @@ -87,18 +113,36 @@ protected function getSchemaFromReflectionProperty( } $variable = $docParser?->variable(); + $description = $this->resolvePropertyDescription($variable, $reflectionProperty, $nodeCollection); - // Add the description to the schema if it exists - if ($variable?->description !== null) { - $jsonSchema->description($variable->description); + if ($description !== null) { + $jsonSchema->description($description); + } + + if ($jsonSchema instanceof ArraySchema) { + $this->applyArrayItems( + $jsonSchema, + $this->resolvePropertyItemTypes($variable, $reflectionProperty, $nodeCollection), + ); } if ($type === null || $type->allowsNull()) { $jsonSchema->nullable(); } - if ($reflectionProperty->hasDefaultValue()) { - $defaultValue = $reflectionProperty->getDefaultValue(); + // Promoted properties report their default value on the constructor + // parameter rather than on the property itself. + $promotedParameter = $reflectionProperty->isPromoted() + ? $this->getConstructorParameter($reflectionProperty->getName()) + : null; + + $hasDefault = $reflectionProperty->hasDefaultValue() + || $promotedParameter?->isDefaultValueAvailable() === true; + + if ($hasDefault) { + $defaultValue = $reflectionProperty->hasDefaultValue() + ? $reflectionProperty->getDefaultValue() + : $promotedParameter?->getDefaultValue(); // If the default value is a backed enum, use its value if ($defaultValue instanceof BackedEnum) { @@ -128,6 +172,50 @@ protected function getSchemaFromReflectionProperty( return $jsonSchema; } + /** + * Resolve a property description from `@var` or promoted constructor `@param` tags. + * + * @param \Cortex\JsonSchema\Support\NodeCollection|null $nodeCollection + */ + protected function resolvePropertyDescription( + ?NodeData $nodeData, + ReflectionProperty $reflectionProperty, + ?NodeCollection $nodeCollection, + ): ?string { + if ($nodeData?->description !== null) { + return $nodeData->description; + } + + if ($reflectionProperty->isPromoted()) { + return $nodeCollection?->get($reflectionProperty->getName())?->description; + } + + return null; + } + + /** + * Resolve array element types from `@var` or promoted constructor `@param` tags. + * + * @param \Cortex\JsonSchema\Support\NodeCollection|null $nodeCollection + * + * @return array + */ + protected function resolvePropertyItemTypes( + ?NodeData $nodeData, + ReflectionProperty $reflectionProperty, + ?NodeCollection $nodeCollection, + ): array { + $itemTypes = $nodeData instanceof NodeData ? $nodeData->itemTypes : []; + + if ($itemTypes !== [] || ! $reflectionProperty->isPromoted()) { + return $itemTypes; + } + + $promotedNode = $nodeCollection?->get($reflectionProperty->getName()); + + return $promotedNode instanceof NodeData ? $promotedNode->itemTypes : []; + } + /** * @param ReflectionProperty|ReflectionClass $reflection */ @@ -139,4 +227,35 @@ protected function getDocParser(ReflectionProperty|ReflectionClass $reflection): ? new DocParser($docComment) : null; } + + /** + * Parse the constructor's `@param` tags, used to describe promoted properties. + * + * @return \Cortex\JsonSchema\Support\NodeCollection|null + */ + protected function getConstructorParams(): ?NodeCollection + { + $constructor = $this->reflection->getConstructor(); + $docComment = $constructor?->getDocComment(); + + return is_string($docComment) + ? (new DocParser($docComment))->params() + : null; + } + + /** + * Resolve the constructor parameter matching the given (promoted) property name. + */ + protected function getConstructorParameter(string $name): ?ReflectionParameter + { + $constructor = $this->reflection->getConstructor(); + + foreach ($constructor?->getParameters() ?? [] as $parameter) { + if ($parameter->getName() === $name) { + return $parameter; + } + } + + return null; + } } diff --git a/src/Converters/ClosureConverter.php b/src/Converters/ClosureConverter.php index 545c98a..9f10d4a 100644 --- a/src/Converters/ClosureConverter.php +++ b/src/Converters/ClosureConverter.php @@ -10,7 +10,9 @@ use ReflectionFunction; use ReflectionNamedType; use ReflectionParameter; +use Cortex\JsonSchema\Support\NodeData; use Cortex\JsonSchema\Support\DocParser; +use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Contracts\Converter; use Cortex\JsonSchema\Enums\SchemaVersion; @@ -96,6 +98,13 @@ protected function getSchemaFromReflectionParameter( $jsonSchema->description($docParam->description); } + if ($jsonSchema instanceof ArraySchema) { + $this->applyArrayItems( + $jsonSchema, + $docParam instanceof NodeData ? $docParam->itemTypes : [], + ); + } + if ($type === null || $type->allowsNull()) { $jsonSchema->nullable(); } diff --git a/src/Converters/Concerns/InteractsWithTypes.php b/src/Converters/Concerns/InteractsWithTypes.php index c9f01c0..f7b9d4b 100644 --- a/src/Converters/Concerns/InteractsWithTypes.php +++ b/src/Converters/Concerns/InteractsWithTypes.php @@ -9,6 +9,7 @@ use ReflectionUnionType; use ReflectionIntersectionType; use Cortex\JsonSchema\Enums\SchemaType; +use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\UnionSchema; use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -57,4 +58,48 @@ protected function resolveSchemaType(ReflectionNamedType $reflectionNamedType): return SchemaType::fromScalar($typeName); } + + /** + * Apply docblock-derived item types to an array schema when mappable. + * + * @param array $itemTypes + */ + protected function applyArrayItems(ArraySchema $arraySchema, array $itemTypes): void + { + $itemsSchema = $this->getItemsSchema($itemTypes); + + if ($itemsSchema !== null) { + $arraySchema->items($itemsSchema); + } + } + + /** + * Build an items schema from docblock element type strings. + * + * @param array $itemTypes + */ + protected function getItemsSchema(array $itemTypes): ?JsonSchema + { + if ($itemTypes === []) { + return null; + } + + $schemaTypes = []; + + foreach ($itemTypes as $itemType) { + $schemaType = SchemaType::tryFromScalar(ltrim($itemType, '\\')); + + if (! $schemaType instanceof SchemaType) { + return null; + } + + if (! in_array($schemaType, $schemaTypes, true)) { + $schemaTypes[] = $schemaType; + } + } + + return count($schemaTypes) === 1 + ? $schemaTypes[0]->instance(null, $this->version) + : new UnionSchema($schemaTypes, null, $this->version); + } } diff --git a/src/Enums/SchemaType.php b/src/Enums/SchemaType.php index e417f94..2bfbe09 100644 --- a/src/Enums/SchemaType.php +++ b/src/Enums/SchemaType.php @@ -56,4 +56,21 @@ public static function fromScalar(string $type): self default => throw UnknownTypeException::forType($type), }; } + + /** + * Attempt to create a schema type from a given scalar type, returning null for unknown types. + */ + public static function tryFromScalar(string $type): ?self + { + return match ($type) { + 'int', 'integer' => self::Integer, + 'float', 'double' => self::Number, + 'string' => self::String, + 'array' => self::Array, + 'bool', 'boolean', 'true', 'false' => self::Boolean, + 'object' => self::Object, + 'null' => self::Null, + default => null, + }; + } } diff --git a/src/Schema.php b/src/Schema.php index 5a8fefc..2e4bccd 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -134,11 +134,13 @@ public static function fromClass( object|string $class, bool $publicOnly = true, ?SchemaVersion $schemaVersion = null, + bool $ignoreUnknownTypes = false, ): ObjectSchema { $classConverter = new ClassConverter( $class, $publicOnly, $schemaVersion ?? self::getDefaultVersion(), + $ignoreUnknownTypes, ); return $classConverter->convert(); @@ -179,6 +181,7 @@ public static function from( $value, true, $schemaVersion, + $ignoreUnknownTypes, ), // @phpstan-ignore argument.type is_array($value) || (is_string($value) && json_validate($value)) => self::fromJson($value, $schemaVersion), diff --git a/src/Support/DocParser.php b/src/Support/DocParser.php index 4bc9319..6a5ba33 100644 --- a/src/Support/DocParser.php +++ b/src/Support/DocParser.php @@ -11,8 +11,10 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode; @@ -45,10 +47,10 @@ public function description(): ?string public function params(): NodeCollection { $nodes = array_map( - static fn(ParamTagValueNode|TypelessParamTagValueNode $param): NodeData => new NodeData( - name: ltrim($param->parameterName, '$'), - description: $param->description === '' ? null : $param->description, - types: self::mapValueNodeToTypes($param), + static fn(ParamTagValueNode|TypelessParamTagValueNode $param): NodeData => self::createNodeData( + ltrim($param->parameterName, '$'), + $param->description, + $param, ), array_merge( $this->parse()->getParamTagValues(), @@ -65,10 +67,10 @@ public function params(): NodeCollection public function variable(): ?NodeData { $vars = array_map( - static fn(VarTagValueNode $varTagValueNode): NodeData => new NodeData( - name: ltrim($varTagValueNode->variableName, '$'), - description: $varTagValueNode->description === '' ? null : $varTagValueNode->description, - types: self::mapValueNodeToTypes($varTagValueNode), + static fn(VarTagValueNode $varTagValueNode): NodeData => self::createNodeData( + ltrim($varTagValueNode->variableName, '$'), + $varTagValueNode->description, + $varTagValueNode, ), $this->parse()->getVarTagValues(), ); @@ -85,6 +87,19 @@ public function isDeprecated(): bool return $this->parse()->getTagsByName('@deprecated') !== []; } + protected static function createNodeData( + string $name, + string $description, + ParamTagValueNode|TypelessParamTagValueNode|VarTagValueNode $tag, + ): NodeData { + return new NodeData( + name: $name, + description: $description === '' ? null : $description, + types: self::mapValueNodeToTypes($tag), + itemTypes: self::mapValueNodeToItemTypes($tag), + ); + } + /** * Map the value node to its types. * @@ -110,6 +125,79 @@ protected static function mapValueNodeToTypes( }; } + /** + * Map the value node to its array element types. + * + * @return array + */ + protected static function mapValueNodeToItemTypes( + ParamTagValueNode|TypelessParamTagValueNode|VarTagValueNode $param, + ): array { + if ($param instanceof TypelessParamTagValueNode) { + return []; + } + + return self::extractItemTypesFromTypeNode($param->type); + } + + /** + * Extract array element types from a type node. + * + * @return array + */ + protected static function extractItemTypesFromTypeNode(TypeNode $typeNode): array + { + return match (true) { + $typeNode instanceof ArrayTypeNode => self::typeNodeToStrings($typeNode->type), + $typeNode instanceof GenericTypeNode => self::extractGenericArrayItemTypes($typeNode), + default => [], + }; + } + + /** + * Extract element types from generic array/list types. + * + * @return array + */ + protected static function extractGenericArrayItemTypes(GenericTypeNode $genericTypeNode): array + { + $baseName = strtolower($genericTypeNode->type->name); + + if (! in_array($baseName, ['array', 'list', 'iterable', 'non-empty-array', 'non-empty-list'], true)) { + return []; + } + + if ($genericTypeNode->genericTypes === []) { + return []; + } + + $valueType = $genericTypeNode->genericTypes[array_key_last($genericTypeNode->genericTypes)]; + + return self::typeNodeToStrings($valueType); + } + + /** + * Resolve a type node to its constituent type strings. + * + * @return array + */ + protected static function typeNodeToStrings(TypeNode $typeNode): array + { + return match (true) { + $typeNode instanceof UnionTypeNode => array_merge( + ...array_map( + self::typeNodeToStrings(...), + $typeNode->types, + ), + ), + $typeNode instanceof NullableTypeNode => [ + ...self::typeNodeToStrings($typeNode->type), + 'null', + ], + default => [(string) $typeNode], + }; + } + /** * Parse the docblock into a PHPStan PhpDocNode. */ diff --git a/src/Support/NodeData.php b/src/Support/NodeData.php index 55c4e98..6ffe638 100644 --- a/src/Support/NodeData.php +++ b/src/Support/NodeData.php @@ -8,10 +8,12 @@ class NodeData { /** * @param array $types + * @param array $itemTypes */ public function __construct( public string $name, public ?string $description = null, public array $types = [], + public array $itemTypes = [], ) {} } diff --git a/tests/Unit/Converters/ClassConverterTest.php b/tests/Unit/Converters/ClassConverterTest.php index 4e8e7df..35b251c 100644 --- a/tests/Unit/Converters/ClassConverterTest.php +++ b/tests/Unit/Converters/ClassConverterTest.php @@ -4,8 +4,10 @@ namespace Cortex\JsonSchema\Tests\Unit\Converters; +use DateTime; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Converters\ClassConverter; +use Cortex\JsonSchema\Exceptions\UnknownTypeException; covers(ClassConverter::class); @@ -116,11 +118,11 @@ public function __construct( ], 'age' => [ 'type' => 'integer', + 'default' => 20, ], ], 'required' => [ 'name', - 'age', ], ]); }); @@ -479,3 +481,191 @@ class CompleteUserCombinedTest extends BaseEntityCombinedTest expect($array['properties'])->toHaveKey('name'); expect($array['properties'])->toHaveKey('email'); }); + +it('ignores static properties', function (): void { + $objectSchema = (new ClassConverter(new class () { + public string $name = 'John'; + + public static string $table = 'users'; + + public static int $count = 0; + }))->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); + + $array = $objectSchema->toArray(); + + expect($array['properties'])->toHaveKey('name'); + expect($array['properties'])->not->toHaveKey('table'); + expect($array['properties'])->not->toHaveKey('count'); +}); + +it('uses constructor @param tags to describe promoted properties', function (): void { + /** + * A user data transfer object + */ + $class = new class ('John Doe') { + /** + * @param string $name The name of the user + * @param int $age The age of the user in years + */ + public function __construct( + public string $name, + public int $age = 20, + ) {} + }; + + $objectSchema = (new ClassConverter($class))->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); + expect($objectSchema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'A user data transfer object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user', + ], + 'age' => [ + 'type' => 'integer', + 'description' => 'The age of the user in years', + 'default' => 20, + ], + ], + 'required' => [ + 'name', + ], + ]); +}); + +it('prefers a property @var description over the constructor @param description', function (): void { + $class = new class ('John Doe') { + /** + * @param string $name This should be ignored in favour of the @var tag + */ + public function __construct( + /** + * @var string The canonical name description + */ + public string $name, + ) {} + }; + + $objectSchema = (new ClassConverter($class))->convert(); + + $array = $objectSchema->toArray(); + + expect($array['properties']['name']['description'])->toBe('The canonical name description'); +}); + +it('throws for unknown property types by default', function (): void { + $class = new class () { + public DateTime $createdAt; + }; + + expect(fn(): ObjectSchema => (new ClassConverter($class))->convert()) + ->toThrow(UnknownTypeException::class); +}); + +it('skips unknown property types when ignoreUnknownTypes is enabled', function (): void { + $class = new class () { + public string $name; + + public DateTime $createdAt; + }; + + $objectSchema = (new ClassConverter($class, ignoreUnknownTypes: true))->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); + + $array = $objectSchema->toArray(); + + expect($array['properties'])->toHaveKey('name'); + expect($array['properties'])->not->toHaveKey('createdAt'); +}); + +it('can create array items schema from @var string[] docblock', function (): void { + $objectSchema = (new ClassConverter(new class () { + /** + * @var string[] The user's tags + */ + public array $tags; + }))->convert(); + + expect($objectSchema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'description' => "The user's tags", + 'items' => [ + 'type' => 'string', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + ], + ], + ], + 'required' => [ + 'tags', + ], + ]); +}); + +it('can create array items schema from promoted @param int[] docblock', function (): void { + $class = new class ([]) { + /** + * @param int[] $ids The user identifiers + */ + public function __construct( + public array $ids, + ) {} + }; + + $objectSchema = (new ClassConverter($class))->convert(); + + expect($objectSchema->toArray()['properties']['ids'])->toBe([ + 'type' => 'array', + 'description' => 'The user identifiers', + 'items' => [ + 'type' => 'integer', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + ], + ]); +}); + +it('can create array items schema for nullable array properties', function (): void { + $objectSchema = (new ClassConverter(new class () { + /** + * @var string[] Optional tags + */ + public ?array $tags = null; + }))->convert(); + + expect($objectSchema->toArray()['properties']['tags'])->toBe([ + 'type' => [ + 'array', + 'null', + ], + 'description' => 'Optional tags', + 'default' => null, + 'items' => [ + 'type' => 'string', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + ], + ]); +}); + +it('leaves array properties without items when element type is unmappable', function (): void { + $objectSchema = (new ClassConverter(new class () { + /** + * @var DateTime[] Scheduled dates + */ + public array $dates; + }))->convert(); + + expect($objectSchema->toArray()['properties']['dates'])->toBe([ + 'type' => 'array', + 'description' => 'Scheduled dates', + ]); +}); diff --git a/tests/Unit/Converters/ClosureConverterTest.php b/tests/Unit/Converters/ClosureConverterTest.php index 4dc9d5b..e128a2a 100644 --- a/tests/Unit/Converters/ClosureConverterTest.php +++ b/tests/Unit/Converters/ClosureConverterTest.php @@ -475,3 +475,20 @@ public function __construct( ], ]); }); + +it('can create array items schema from @param string[] docblock', function (): void { + /** + * @param string[] $tags The user's tags + */ + $closure = function (array $tags): void {}; + $objectSchema = (new ClosureConverter($closure))->convert(); + + expect($objectSchema->toArray()['properties']['tags'])->toBe([ + 'type' => 'array', + 'description' => "The user's tags", + 'items' => [ + 'type' => 'string', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + ], + ]); +}); diff --git a/tests/Unit/Enums/SchemaTypeTest.php b/tests/Unit/Enums/SchemaTypeTest.php index dad7e00..9f7c561 100644 --- a/tests/Unit/Enums/SchemaTypeTest.php +++ b/tests/Unit/Enums/SchemaTypeTest.php @@ -31,6 +31,26 @@ ->toThrow(UnknownTypeException::class, 'Unknown type: unknown'); }); +it('can resolve scalar types with tryFromScalar', function (string $input, SchemaType $schemaType): void { + expect(SchemaType::tryFromScalar($input))->toBe($schemaType); +})->with([ + 'int' => ['int', SchemaType::Integer], + 'integer' => ['integer', SchemaType::Integer], + 'float' => ['float', SchemaType::Number], + 'double' => ['double', SchemaType::Number], + 'string' => ['string', SchemaType::String], + 'bool' => ['bool', SchemaType::Boolean], + 'boolean' => ['boolean', SchemaType::Boolean], + 'true' => ['true', SchemaType::Boolean], + 'false' => ['false', SchemaType::Boolean], + 'null' => ['null', SchemaType::Null], +]); + +it('returns null from tryFromScalar for unknown types', function (): void { + expect(SchemaType::tryFromScalar('DateTime'))->toBeNull(); + expect(SchemaType::tryFromScalar('mixed'))->toBeNull(); +}); + it('can create schema instance', function (SchemaType $schemaType, string $expectedClass): void { expect($schemaType->instance())->toBeInstanceOf($expectedClass); })->with([ diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php index efd7273..757935a 100644 --- a/tests/Unit/SchemaTest.php +++ b/tests/Unit/SchemaTest.php @@ -123,11 +123,11 @@ public function __construct( ], 'age' => [ 'type' => 'integer', + 'default' => 20, ], ], 'required' => [ 'name', - 'age', ], ]); diff --git a/tests/Unit/Support/DocParserTest.php b/tests/Unit/Support/DocParserTest.php index 9d654cc..39efa3c 100644 --- a/tests/Unit/Support/DocParserTest.php +++ b/tests/Unit/Support/DocParserTest.php @@ -407,6 +407,35 @@ expect($parser->isDeprecated())->toBeTrue(); }); +it( + 'can parse array item types from docblocks', + function (string $docblock, array $expectedItemTypes, string $source): void { + $parser = new DocParser($docblock); + + $node = $source === 'params' + ? $parser->params()->get('tags') + : $parser->variable(); + + expect($node?->itemTypes)->toBe($expectedItemTypes); + }, +)->with([ + 'string[]' => ['/** @var string[] $tags */', ['string'], 'variable'], + 'array' => ['/** @var array $tags */', ['string'], 'variable'], + 'array' => ['/** @var array $scores */', ['int'], 'variable'], + 'list' => ['/** @var list $flags */', ['bool'], 'variable'], + 'array' => ['/** @var array $values */', ['int', 'string'], 'variable'], + '(int|string)[]' => ['/** @var (int|string)[] $values */', ['int', 'string'], 'variable'], + 'DateTime[]' => ['/** @var DateTime[] $dates */', ['DateTime'], 'variable'], + 'param string[]' => ['/** @param string[] $tags The tags */', ['string'], 'params'], +]); + +it('returns empty item types for non-array docblock types', function (): void { + $docblock = '/** @var string $name The name */'; + $parser = new DocParser($docblock); + + expect($parser->variable()?->itemTypes)->toBe([]); +}); + it('handles deprecation with complex docblock', function (): void { $docblock = <<<'EOD' /**