├── LICENSE ├── composer.json ├── context.yaml ├── psalm-baseline.xml ├── rector.php └── src ├── AbstractDefinition.php ├── Attribute ├── AdditionalProperties.php ├── Constraint │ ├── Enum.php │ ├── Items.php │ ├── Length.php │ ├── MultipleOf.php │ ├── Pattern.php │ └── Range.php └── Field.php ├── Exception ├── DefinitionException.php ├── GeneratorException.php └── InvalidTypeException.php ├── Generator.php ├── GeneratorInterface.php ├── Parser ├── ClassParser.php ├── ClassParserInterface.php ├── Parser.php ├── ParserInterface.php ├── Property.php ├── PropertyInterface.php ├── SimpleType.php ├── Type.php └── TypeInterface.php ├── Schema.php ├── Schema ├── Definition.php ├── Format.php ├── Property.php ├── PropertyOption.php ├── PropertyOptions.php ├── PropertyType.php ├── Reference.php └── Type.php └── Validation ├── AdditionalPropertiesExtractor.php ├── AttributeConstraintExtractor.php ├── CompositePropertyDataExtractor.php ├── Constraint ├── AbstractConstraint.php ├── ArrayConstraint.php ├── NumericConstraint.php └── StringConstraint.php ├── ConstraintMapper.php ├── DocBlockParser.php ├── PhpDocValidationConstraintExtractor.php └── PropertyDataExtractorInterface.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Spiral Scout 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spiral/json-schema-generator", 3 | "description": "Provides the ability to generate JSON schemas from Data Transfer Object (DTO) classes", 4 | "keywords": [], 5 | "homepage": "https://github.com/spiral/json-schema-generator", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Anton Titov (wolfy-j)", 10 | "email": "wolfy-j@spiralscout.com" 11 | }, 12 | { 13 | "name": "Pavel Buchnev (butschster)", 14 | "email": "pavel.buchnev@spiralscout.com" 15 | }, 16 | { 17 | "name": "Aleksei Gagarin (roxblnfk)", 18 | "email": "alexey.gagarin@spiralscout.com" 19 | }, 20 | { 21 | "name": "Maksim Smakouz (msmakouz)", 22 | "email": "maksim.smakouz@spiralscout.com" 23 | } 24 | ], 25 | "require": { 26 | "php": ">=8.3", 27 | "symfony/property-info": "^7.2.0 || ^8.0.0", 28 | "phpstan/phpdoc-parser": "^1.33 | ^2.1", 29 | "phpdocumentor/reflection-docblock": "^5.3" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^10.5.45", 33 | "spiral/code-style": "^2.2.2", 34 | "vimeo/psalm": "^6.10", 35 | "rector/rector": "^2.0" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Spiral\\JsonSchemaGenerator\\": "src" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Spiral\\JsonSchemaGenerator\\Tests\\": "tests" 45 | } 46 | }, 47 | "scripts": { 48 | "cs:fix": "php-cs-fixer fix -v", 49 | "psalm": "psalm", 50 | "refactor": "rector process --config=rector.php", 51 | "refactor:ci": "rector process --config=rector.php --dry-run --ansi", 52 | "test": "phpunit", 53 | "test-coverage": "phpunit --coverage", 54 | "psalm:baseline": "psalm --set-baseline=psalm-baseline.xml" 55 | }, 56 | "config": { 57 | "sort-packages": true 58 | }, 59 | "minimum-stability": "dev", 60 | "prefer-stable": true 61 | } 62 | -------------------------------------------------------------------------------- /context.yaml: -------------------------------------------------------------------------------- 1 | $schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' 2 | 3 | tools: 4 | - id: run-tests 5 | description: 'Run tests' 6 | type: run 7 | commands: 8 | - cmd: vendor/bin/phpunit 9 | 10 | documents: 11 | - description: 'Project structure overview' 12 | outputPath: project-structure.md 13 | sources: 14 | - type: tree 15 | sourcePaths: 16 | - src 17 | showCharCount: true 18 | 19 | - description: Source code 20 | outputPath: source-code.md 21 | sources: 22 | - type: file 23 | sourcePaths: 24 | - src 25 | -------------------------------------------------------------------------------- /psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]); 15 | 16 | // Register rules for PHP 8.4 migration 17 | $rectorConfig->sets([ 18 | SetList::PHP_83, 19 | LevelSetList::UP_TO_PHP_83, 20 | ]); 21 | 22 | // Skip vendor directories 23 | $rectorConfig->skip([ 24 | __DIR__ . '/vendor', 25 | AddOverrideAttributeToOverriddenMethodsRector::class, 26 | ]); 27 | }; 28 | -------------------------------------------------------------------------------- /src/AbstractDefinition.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected array $properties = []; 18 | 19 | /** 20 | * @param non-empty-string $name 21 | */ 22 | public function addProperty(string $name, Property $property): self 23 | { 24 | $this->properties[$name] = $property; 25 | 26 | return $this; 27 | } 28 | 29 | protected function renderProperties(array $schema): array 30 | { 31 | if ($this->properties === []) { 32 | return $schema; 33 | } 34 | 35 | $schema['properties'] = []; 36 | 37 | // Building properties 38 | $required = []; 39 | foreach ($this->properties as $name => $property) { 40 | $schema['properties'][$name] = $property->jsonSerialize(); 41 | 42 | if ($property->required) { 43 | $required[] = $name; 44 | } 45 | } 46 | 47 | if ($required !== []) { 48 | $schema['required'] = $required; 49 | } 50 | 51 | return $schema; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Attribute/AdditionalProperties.php: -------------------------------------------------------------------------------- 1 | propertyDataExtractor = $propertyDataExtractor ?? CompositePropertyDataExtractor::createDefault(); 31 | } 32 | 33 | /** 34 | * @param class-string|\ReflectionClass $class 35 | */ 36 | public function generate(string|\ReflectionClass $class): Schema 37 | { 38 | $class = $this->parser->parse($class); 39 | 40 | // check cached 41 | if (isset($this->cache[$class->getName()])) { 42 | return $this->cache[$class->getName()]; 43 | } 44 | 45 | $schema = new Schema(); 46 | 47 | $dependencies = []; 48 | // Generating properties 49 | foreach ($class->getProperties() as $property) { 50 | $psc = $this->generateProperty($property); 51 | if ($psc === null) { 52 | continue; 53 | } 54 | 55 | // does it refer to any other classes 56 | $dependencies = [...$dependencies, ...$psc->getDependencies()]; 57 | 58 | $schema->addProperty($property->getName(), $psc); 59 | } 60 | 61 | // Generating dependencies 62 | $dependencies = \array_unique($dependencies); 63 | $rollingDependencies = []; 64 | $doneDependencies = []; 65 | 66 | do { 67 | foreach ($dependencies as $dependency) { 68 | $dependency = $this->parser->parse($dependency); 69 | $definition = $this->generateDefinition($dependency, $rollingDependencies); 70 | if ($definition === null) { 71 | continue; 72 | } 73 | 74 | $schema->addDefinition($dependency->getShortName(), $definition); 75 | } 76 | 77 | $doneDependencies = [...$doneDependencies, ...$dependencies]; 78 | $rollingDependencies = \array_diff($rollingDependencies, $doneDependencies); 79 | if ($rollingDependencies === []) { 80 | break; 81 | } 82 | 83 | $dependencies = $rollingDependencies; 84 | } while (true); 85 | 86 | // caching 87 | $this->cache[$class->getName()] = $schema; 88 | 89 | return $schema; 90 | } 91 | 92 | protected function generateDefinition(ClassParserInterface $class, array &$dependencies = []): ?Definition 93 | { 94 | $properties = []; 95 | // class properties 96 | foreach ($class->getProperties() as $property) { 97 | $psc = $this->generateProperty($property); 98 | if ($psc === null) { 99 | continue; 100 | } 101 | 102 | $dependencies = [...$dependencies, ...$psc->getDependencies()]; 103 | $properties[$property->getName()] = $psc; 104 | } 105 | 106 | return new Definition(type: $class->getName(), title: $class->getShortName(), properties: $properties); 107 | } 108 | 109 | protected function generateProperty(PropertyInterface $property): ?Property 110 | { 111 | // Looking for Field attribute 112 | $title = ''; 113 | $description = ''; 114 | $default = null; 115 | $format = null; 116 | 117 | $attribute = $property->findAttribute(Field::class); 118 | if ($attribute !== null) { 119 | $title = $attribute->title; 120 | $description = $attribute->description; 121 | $default = $attribute->default; 122 | $format = $attribute->format; 123 | } 124 | 125 | if ($default === null && $property->hasDefaultValue()) { 126 | $default = $property->getDefaultValue(); 127 | } 128 | 129 | $type = $property->getType(); 130 | $propertyTypes = $this->extractPropertyTypes($type); 131 | 132 | // Extract validation constraints using the configurable extractor system 133 | $validationRules = $this->extractValidationConstraints($property, $propertyTypes); 134 | 135 | return new Property( 136 | types: $propertyTypes, 137 | title: $title, 138 | description: $description, 139 | required: $default === null && !$type->allowsNull(), 140 | default: $default, 141 | format: $format, 142 | validationRules: $validationRules, 143 | ); 144 | } 145 | 146 | /** 147 | * @return list 148 | */ 149 | private function extractPropertyTypes(Type $type): array 150 | { 151 | return \array_map(static fn(SimpleType $simpleType) => new PropertyType( 152 | type: $simpleType->getName(), 153 | enum: $simpleType->getEnumValues(), 154 | collectionTypes: $simpleType->isCollection() ? \array_map( 155 | static fn(SimpleType $collectionSimpleType) => new PropertyType( 156 | type: $collectionSimpleType->getName(), 157 | enum: $collectionSimpleType->getEnumValues(), 158 | ), 159 | $simpleType->getCollectionType()?->types ?? [], 160 | ) : null, 161 | ), $type->types); 162 | } 163 | 164 | /** 165 | * Extract validation constraints from property using the configured extractors. 166 | */ 167 | private function extractValidationConstraints(PropertyInterface $property, array $propertyTypes): array 168 | { 169 | $allValidationRules = []; 170 | 171 | foreach ($propertyTypes as $propertyType) { 172 | if ($propertyType->type instanceof Schema\Type) { 173 | $validationRules = $this->propertyDataExtractor->extractValidationRules($property, $propertyType->type); 174 | $allValidationRules = \array_merge($allValidationRules, $validationRules); 175 | } 176 | } 177 | 178 | return $allValidationRules; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/GeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | private array $constructorParameters = []; 33 | 34 | private readonly PropertyTypeExtractorInterface $propertyInfo; 35 | 36 | /** 37 | * @param \ReflectionClass|class-string $class 38 | */ 39 | public function __construct(\ReflectionClass|string $class) 40 | { 41 | if (\is_string($class)) { 42 | try { 43 | $class = new \ReflectionClass($class); 44 | } catch (\ReflectionException $e) { 45 | throw new GeneratorException($e->getMessage(), $e->getCode(), $e); 46 | } 47 | } 48 | 49 | $this->class = $class; 50 | $this->propertyInfo = $this->createPropertyInfo(); 51 | 52 | $constructor = $this->class->getConstructor(); 53 | if ($constructor !== null) { 54 | foreach ($constructor->getParameters() as $parameter) { 55 | if ($parameter->isPromoted()) { 56 | $this->constructorParameters[$parameter->getName()] = $parameter; 57 | } 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * @return class-string 64 | */ 65 | public function getName(): string 66 | { 67 | return $this->class->getName(); 68 | } 69 | 70 | /** 71 | * @return non-empty-string 72 | */ 73 | public function getShortName(): string 74 | { 75 | return $this->class->getShortName(); 76 | } 77 | 78 | /** 79 | * @return array 80 | */ 81 | public function getProperties(): array 82 | { 83 | $properties = []; 84 | foreach ($this->class->getProperties() as $property) { 85 | // skipping private, protected, static properties, properties without type 86 | if ($property->isPrivate() || $property->isProtected() || $property->isStatic() || !$property->hasType()) { 87 | continue; 88 | } 89 | 90 | /** 91 | * @var \ReflectionNamedType|\ReflectionUnionType|null $type 92 | */ 93 | $type = $property->getType(); 94 | if (!$type instanceof \ReflectionNamedType && !$type instanceof \ReflectionUnionType) { 95 | continue; 96 | } 97 | 98 | $properties[] = new Property( 99 | property: $property, 100 | type: $this->getPropertyType($property), 101 | hasDefaultValue: $this->hasPropertyDefaultValue($property), 102 | defaultValue: $this->getPropertyDefaultValue($property), 103 | ); 104 | } 105 | 106 | return $properties; 107 | } 108 | 109 | public function isEnum(): bool 110 | { 111 | return $this->class->isEnum(); 112 | } 113 | 114 | /** 115 | * @param non-empty-string|class-string $typeName 116 | * 117 | * @return list|null 118 | */ 119 | private function getEnumValues(string $typeName): ?array 120 | { 121 | if (!\is_subclass_of($typeName, \BackedEnum::class)) { 122 | return null; 123 | } 124 | 125 | $reflectionEnum = new \ReflectionEnum($typeName); 126 | 127 | return \array_map( 128 | static fn(\ReflectionEnumUnitCase $case): int|string => $case->getValue()->value, 129 | $reflectionEnum->getCases(), 130 | ); 131 | } 132 | 133 | private function getPropertyType(\ReflectionProperty $property): Type 134 | { 135 | $type = $this->propertyInfo->getType($property->class, $property->getName()); 136 | 137 | if ($type === null) { 138 | throw new InvalidTypeException(); 139 | } 140 | 141 | return $this->createType($type); 142 | } 143 | 144 | private function createType(TypeInfoType $type): Type 145 | { 146 | $simpleTypes = []; 147 | if ($type instanceof UnionType) { 148 | foreach ($type->getTypes() as $subType) { 149 | $simpleType = $this->createSimpleType($subType); 150 | if ($simpleType !== null) { 151 | $simpleTypes[] = $simpleType; 152 | } 153 | } 154 | } else { 155 | $simpleType = $this->createSimpleType($type); 156 | if ($simpleType !== null) { 157 | $simpleTypes[] = $simpleType; 158 | } 159 | } 160 | 161 | return new Type(types: $simpleTypes); 162 | } 163 | 164 | private function createSimpleType(TypeInfoType $type): ?SimpleType 165 | { 166 | $typeName = ''; 167 | $builtin = true; 168 | $enum = null; 169 | $collectionType = null; 170 | if ($type instanceof BuiltinType) { 171 | if ($type->getTypeIdentifier() === TypeIdentifier::MIXED) { 172 | return null; 173 | } 174 | $typeName = $type->getTypeIdentifier()->value; 175 | } 176 | if ($type instanceof CollectionType) { 177 | $typeName = TypeIdentifier::ARRAY->value; 178 | $collectionType = $this->createType($type->getCollectionValueType()); 179 | } 180 | if ($type instanceof ObjectType) { 181 | $typeName = $type->getClassName(); 182 | $builtin = false; 183 | } 184 | 185 | if ($type instanceof BackedEnumType) { 186 | $enum = $this->getEnumValues($type->getClassName()); 187 | $typeName = $type->getBackingType()->getTypeIdentifier()->value; 188 | $builtin = true; 189 | } 190 | 191 | if ($typeName === '') { 192 | throw new InvalidTypeException(); 193 | } 194 | 195 | return new SimpleType( 196 | name: $typeName, 197 | builtin: $builtin, 198 | collectionType: $collectionType, 199 | enum: $enum, 200 | ); 201 | } 202 | 203 | private function hasPropertyDefaultValue(\ReflectionProperty $property): bool 204 | { 205 | $parameter = $this->constructorParameters[$property->getName()] ?? null; 206 | 207 | return $property->hasDefaultValue() || ($parameter !== null && $parameter->isDefaultValueAvailable()); 208 | } 209 | 210 | private function getPropertyDefaultValue(\ReflectionProperty $property): mixed 211 | { 212 | if ($property->hasDefaultValue()) { 213 | $default = $property->getDefaultValue(); 214 | } 215 | 216 | $parameter = $this->constructorParameters[$property->getName()] ?? null; 217 | if ($parameter !== null && $property->isPromoted() && $parameter->isDefaultValueAvailable()) { 218 | $default = $parameter->getDefaultValue(); 219 | } 220 | 221 | return $default ?? null; 222 | } 223 | 224 | private function createPropertyInfo(): PropertyTypeExtractorInterface 225 | { 226 | return new PropertyInfoExtractor(typeExtractors: [ 227 | new PhpStanExtractor(), 228 | new PhpDocExtractor(), 229 | new ReflectionExtractor(), 230 | ]); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Parser/ClassParserInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function getProperties(): array; 23 | } 24 | -------------------------------------------------------------------------------- /src/Parser/Parser.php: -------------------------------------------------------------------------------- 1 | property->getName(); 25 | } 26 | 27 | /** 28 | * @template T 29 | * 30 | * @param class-string $name The class name of the attribute. 31 | * 32 | * @return T|null The attribute or {@see null}, if the requested attribute does not exist. 33 | */ 34 | public function findAttribute(string $name): ?object 35 | { 36 | $name = $this->property->getAttributes($name); 37 | if ($name !== []) { 38 | return $name[0]->newInstance(); 39 | } 40 | 41 | return null; 42 | } 43 | 44 | public function hasDefaultValue(): bool 45 | { 46 | return $this->hasDefaultValue; 47 | } 48 | 49 | public function getDefaultValue(): mixed 50 | { 51 | return $this->defaultValue; 52 | } 53 | 54 | public function getType(): Type 55 | { 56 | return $this->type; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Parser/PropertyInterface.php: -------------------------------------------------------------------------------- 1 | $name The class name of the attribute. 18 | * 19 | * @return T|null The attribute or {@see null}, if the requested attribute does not exist. 20 | */ 21 | public function findAttribute(string $name): ?object; 22 | 23 | public function hasDefaultValue(): bool; 24 | 25 | public function getDefaultValue(): mixed; 26 | 27 | public function getType(): Type; 28 | } 29 | -------------------------------------------------------------------------------- /src/Parser/SimpleType.php: -------------------------------------------------------------------------------- 1 | |null $enum 22 | */ 23 | public function __construct( 24 | string $name, 25 | private bool $builtin, 26 | private ?Type $collectionType = null, 27 | private ?array $enum = null, 28 | ) { 29 | /** @psalm-suppress PropertyTypeCoercion */ 30 | $this->name = $this->builtin ? SchemaType::fromBuiltIn($name) : $name; 31 | } 32 | 33 | /** 34 | * @return class-string|SchemaType 35 | */ 36 | public function getName(): string|SchemaType 37 | { 38 | return $this->name; 39 | } 40 | 41 | public function isBuiltin(): bool 42 | { 43 | return $this->builtin; 44 | } 45 | 46 | public function isEnum(): bool 47 | { 48 | return $this->enum !== null; 49 | } 50 | 51 | /** 52 | * @return list|null 53 | */ 54 | public function getEnumValues(): ?array 55 | { 56 | return $this->enum; 57 | } 58 | 59 | public function isCollection(): bool 60 | { 61 | return $this->collectionType !== null; 62 | } 63 | 64 | public function getCollectionType(): ?Type 65 | { 66 | return $this->collectionType; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Parser/Type.php: -------------------------------------------------------------------------------- 1 | $types 16 | */ 17 | public function __construct( 18 | public array $types, 19 | ) {} 20 | 21 | public function allowsNull(): bool 22 | { 23 | return \count(\array_filter($this->types, static fn(SimpleType $type): bool => $type->getName() === SchemaType::Null)) !== 0; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Parser/TypeInterface.php: -------------------------------------------------------------------------------- 1 | definitions[$name] = $definition; 19 | return $this; 20 | } 21 | 22 | public function jsonSerialize(): array 23 | { 24 | $schema = $this->renderProperties(['type' => 'object']); 25 | 26 | if ($this->definitions !== []) { 27 | $schema['definitions'] = []; 28 | 29 | foreach ($this->definitions as $name => $definition) { 30 | $schema['definitions'][$name] = $definition->jsonSerialize(); 31 | } 32 | } 33 | 34 | return $schema; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Schema/Definition.php: -------------------------------------------------------------------------------- 1 | $property) { 26 | if (!$property instanceof Property) { 27 | throw new DefinitionException(\sprintf( 28 | 'Property `%s` is not an instance of `%s`.', 29 | // type name or class name 30 | \get_debug_type($property), 31 | Property::class, 32 | )); 33 | } 34 | 35 | $this->addProperty($name, $property); 36 | } 37 | } 38 | 39 | public function jsonSerialize(): array 40 | { 41 | $schema = []; 42 | if ($this->title !== '') { 43 | $schema['title'] = $this->title; 44 | } 45 | 46 | if ($this->description !== '') { 47 | $schema['description'] = $this->description; 48 | } 49 | 50 | if ($this->type instanceof Type) { 51 | $schema['type'] = $this->type->value; 52 | } else { 53 | $schema = $this->renderType($schema); 54 | } 55 | 56 | return $this->renderProperties($schema); 57 | } 58 | 59 | private function renderType(array $schema): array 60 | { 61 | if ($this->properties !== []) { 62 | $schema['type'] = 'object'; 63 | return $this->renderProperties($schema); 64 | } 65 | 66 | $rf = new \ReflectionClass($this->type); 67 | if (!$rf->isEnum()) { 68 | throw new DefinitionException(\sprintf( 69 | 'SimpleType `%s` must be a backed enum or class with properties.', 70 | $this->type instanceof Type ? $this->type->value : $this->type, 71 | )); 72 | } 73 | 74 | $rf = new \ReflectionEnum($this->type); 75 | 76 | /** @var \ReflectionEnum $rf */ 77 | if (!$rf->isBacked()) { 78 | throw new DefinitionException(\sprintf( 79 | 'SimpleType `%s` is not a backed enum.', 80 | $this->type instanceof Type ? $this->type->value : $this->type, 81 | )); 82 | } 83 | 84 | /** 85 | * @var \ReflectionNamedType $type 86 | */ 87 | $type = $rf->getBackingType(); 88 | 89 | // mapping to json schema type 90 | $schema['type'] = match ($type->getName()) { 91 | 'int', 'float' => 'number', 92 | 'string' => 'string', 93 | 'bool' => 'boolean', 94 | default => throw new DefinitionException(\sprintf( 95 | 'SimpleType `%s` is not a backed enum.', 96 | $this->type instanceof Type ? $this->type->value : $this->type, 97 | )), 98 | }; 99 | 100 | // options are scalar values at this point 101 | $schema['enum'] = $this->options; 102 | 103 | return $schema; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Schema/Format.php: -------------------------------------------------------------------------------- 1 | $types 14 | * @param array $validationRules 15 | */ 16 | public function __construct( 17 | public array $types, 18 | public string $title = '', 19 | public string $description = '', 20 | public bool $required = false, 21 | public mixed $default = null, 22 | public ?Format $format = null, 23 | public array $validationRules = [], // NEW: Validation rules from PHPDoc 24 | ) {} 25 | 26 | public function jsonSerialize(): array 27 | { 28 | $property = []; 29 | if ($this->title !== '') { 30 | $property['title'] = $this->title; 31 | } 32 | 33 | if ($this->description !== '') { 34 | $property['description'] = $this->description; 35 | } 36 | 37 | if ($this->default !== null) { 38 | $property['default'] = $this->default; 39 | } 40 | 41 | if ($this->format instanceof Format) { 42 | $property['format'] = $this->format->value; 43 | } 44 | 45 | // Check if we have an array shape constraint that should override the type 46 | if (isset($this->validationRules['type']) && $this->validationRules['type'] === 'object') { 47 | // Array shape or additional properties overrides normal type processing 48 | $property = \array_merge($property, $this->validationRules); 49 | 50 | // Clean up internal metadata keys 51 | unset($property['_additionalPropertiesClass']); 52 | 53 | return $property; 54 | } 55 | 56 | $typesCount = \count($this->types); 57 | if ($typesCount > 1) { 58 | foreach ($this->types as $type) { 59 | $property['oneOf'][] = $this->propertyTypeToDefinition($type); 60 | } 61 | } elseif ($typesCount === 1) { 62 | $property = \array_merge($property, $this->propertyTypeToDefinition($this->types[0])); 63 | } 64 | 65 | // Apply validation rules from PHPDoc constraints (except type overrides) 66 | $filteredValidationRules = $this->validationRules; 67 | if (isset($filteredValidationRules['type'])) { 68 | unset($filteredValidationRules['type'], $filteredValidationRules['properties'], $filteredValidationRules['required'], $filteredValidationRules['additionalProperties'], $filteredValidationRules['_additionalPropertiesClass']); 69 | } 70 | $property = \array_merge($property, $filteredValidationRules); 71 | 72 | return $property; 73 | } 74 | 75 | public function getDependencies(): array 76 | { 77 | $dependencies = []; 78 | foreach ($this->types as $type) { 79 | if (\is_string($type->type)) { 80 | $dependencies[] = $type->type; 81 | } 82 | if ($type->type === Type::Array && $type->collectionTypes !== null && $type->collectionTypes !== []) { 83 | foreach ($type->collectionTypes as $collectionType) { 84 | if (\is_string($collectionType->type)) { 85 | $dependencies[] = $collectionType->type; 86 | } 87 | } 88 | } 89 | } 90 | 91 | // Extract dependencies from additional properties references 92 | if (isset($this->validationRules['_additionalPropertiesClass'])) { 93 | $dependencies[] = $this->validationRules['_additionalPropertiesClass']; 94 | } 95 | 96 | return $dependencies; 97 | } 98 | 99 | protected function propertyTypeToDefinition(PropertyType $propertyType): array 100 | { 101 | $property = []; 102 | 103 | if ($propertyType->type instanceof Type) { 104 | $property['type'] = $propertyType->type->value; 105 | if ($propertyType->enum !== null) { 106 | $property['enum'] = $propertyType->enum; 107 | } 108 | if ($propertyType->type === Type::Array && $propertyType->collectionTypes !== null && $propertyType->collectionTypes !== []) { 109 | $collectionTypeCount = \count($propertyType->collectionTypes); 110 | if ($collectionTypeCount > 1) { 111 | foreach ($propertyType->collectionTypes as $collectionType) { 112 | if ($collectionType->type instanceof Type) { 113 | $schemaType = ['type' => $collectionType->type->value]; 114 | if ($collectionType->enum !== null) { 115 | $schemaType['enum'] = $collectionType->enum; 116 | } 117 | $property['items']['anyOf'][] = $schemaType; 118 | } else { 119 | $property['items']['anyOf'][] = [ 120 | '$ref' => (new Reference( 121 | $collectionType->type, 122 | ))->jsonSerialize(), 123 | ]; 124 | } 125 | } 126 | } elseif ($collectionTypeCount === 1) { 127 | $collectionType = $propertyType->collectionTypes[0]; 128 | if ($collectionType->type instanceof Type) { 129 | $property['items'] = ['type' => $collectionType->type->value]; 130 | if ($collectionType->enum !== null) { 131 | $property['items']['enum'] = $collectionType->enum; 132 | } 133 | } else { 134 | $property['items'] = ['$ref' => (new Reference($collectionType->type))->jsonSerialize()]; 135 | } 136 | } 137 | } 138 | } else { 139 | $property['$ref'] = (new Reference($propertyType->type))->jsonSerialize(); 140 | } 141 | 142 | return $property; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Schema/PropertyOption.php: -------------------------------------------------------------------------------- 1 | value) && !\class_exists($this->value)) { 21 | throw new InvalidTypeException('Invalid property option definition.'); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Schema/PropertyOptions.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class PropertyOptions implements \Countable, \ArrayAccess, \JsonSerializable 12 | { 13 | /** 14 | * @var array 15 | */ 16 | private array $options = []; 17 | 18 | /** 19 | * @param array $options 20 | */ 21 | public function __construct(array $options = []) 22 | { 23 | foreach ($options as $option) { 24 | $this->options[] = new PropertyOption($option); 25 | } 26 | } 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function getOptions(): array 32 | { 33 | return $this->options; 34 | } 35 | 36 | public function count(): int 37 | { 38 | return \count($this->options); 39 | } 40 | 41 | public function offsetExists(mixed $offset): bool 42 | { 43 | return isset($this->options[$offset]); 44 | } 45 | 46 | public function offsetGet(mixed $offset): PropertyOption 47 | { 48 | return $this->options[$offset]; 49 | } 50 | 51 | /** 52 | * @param int $offset 53 | * @param PropertyOption|class-string|Type $value 54 | */ 55 | public function offsetSet(mixed $offset, mixed $value): void 56 | { 57 | $this->options[$offset] = $value instanceof PropertyOption ? $value : new PropertyOption($value); 58 | } 59 | 60 | public function offsetUnset(mixed $offset): void 61 | { 62 | unset($this->options[$offset]); 63 | } 64 | 65 | public function jsonSerialize(): array 66 | { 67 | $types = []; 68 | foreach ($this->options as $option) { 69 | if (\is_string($option->value)) { 70 | // reference to class 71 | $types[] = ['$ref' => (new Reference($option->value))->jsonSerialize()]; 72 | continue; 73 | } 74 | 75 | $types[] = ['type' => $option->value->value]; 76 | } 77 | return $types; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Schema/PropertyType.php: -------------------------------------------------------------------------------- 1 | |null $enum 15 | * @param list|null $collectionTypes 16 | */ 17 | public function __construct( 18 | public string|Type $type, 19 | public ?array $enum = null, 20 | public ?array $collectionTypes = null, 21 | ) {} 22 | } 23 | -------------------------------------------------------------------------------- /src/Schema/Reference.php: -------------------------------------------------------------------------------- 1 | className, '\\'); 22 | 23 | return '#/definitions/' . ($pos === false ? $this->className : \substr($this->className, $pos + 1)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Schema/Type.php: -------------------------------------------------------------------------------- 1 | self::String, 22 | 'integer', 'int' => self::Integer, 23 | 'float', 'double', 'number' => self::Number, 24 | 'boolean', 'bool' => self::Boolean, 25 | 'object' => self::Object, 26 | 'array' => self::Array, 27 | 'null' => self::Null, 28 | default => throw new \InvalidArgumentException(\sprintf('Invalid type `%s`.', $type)), 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Validation/AdditionalPropertiesExtractor.php: -------------------------------------------------------------------------------- 1 | findAttribute(AdditionalProperties::class); 24 | if (!$additionalProperties instanceof AdditionalProperties) { 25 | return $validationRules; 26 | } 27 | 28 | // Override array type to object type for additional properties 29 | $validationRules['type'] = 'object'; 30 | 31 | // Process the additional properties value type 32 | $additionalPropertiesSchema = $this->processValueType( 33 | $additionalProperties->valueType, 34 | $additionalProperties->valueClass, 35 | ); 36 | 37 | $validationRules['additionalProperties'] = $additionalPropertiesSchema; 38 | 39 | // Store class dependencies for later extraction 40 | if ($additionalProperties->valueType === 'object' && $additionalProperties->valueClass !== null) { 41 | $validationRules['_additionalPropertiesClass'] = $additionalProperties->valueClass; 42 | } 43 | 44 | return $validationRules; 45 | } 46 | 47 | /** 48 | * Process the value type and return appropriate JSON schema structure. 49 | * 50 | * @param class-string|null $valueClass 51 | */ 52 | private function processValueType(string $valueType, ?string $valueClass): array|bool 53 | { 54 | return match ($valueType) { 55 | 'int', 'integer' => ['type' => 'integer'], 56 | 'number', 'float' => ['type' => 'number'], 57 | 'boolean', 'bool' => ['type' => 'boolean'], 58 | 'mixed' => true, // Allow any type 59 | 'object' => $this->processObjectType($valueClass), 60 | default => ['type' => 'string'], // fallback to string 61 | }; 62 | } 63 | 64 | /** 65 | * Process object type with class reference. 66 | * 67 | * @param class-string|null $valueClass 68 | */ 69 | private function processObjectType(?string $valueClass): array 70 | { 71 | if ($valueClass === null) { 72 | return ['type' => 'object']; 73 | } 74 | 75 | // Create reference to the class definition 76 | return ['$ref' => (new Reference($valueClass))->jsonSerialize()]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Validation/AttributeConstraintExtractor.php: -------------------------------------------------------------------------------- 1 | findAttribute(Pattern::class); 24 | if ($pattern instanceof Pattern) { 25 | $validationRules['pattern'] = $pattern->pattern; 26 | } 27 | 28 | // Extract Length constraint 29 | $length = $property->findAttribute(Length::class); 30 | if ($length instanceof Length) { 31 | if ($length->min !== null) { 32 | $validationRules[$this->getLengthMinKey($jsonSchemaType)] = $length->min; 33 | } 34 | if ($length->max !== null) { 35 | $validationRules[$this->getLengthMaxKey($jsonSchemaType)] = $length->max; 36 | } 37 | } 38 | 39 | // Extract Range constraint 40 | $range = $property->findAttribute(Range::class); 41 | if ($range instanceof Range) { 42 | if ($range->min !== null) { 43 | $key = $range->exclusiveMin === true ? 'exclusiveMinimum' : 'minimum'; 44 | $validationRules[$key] = $range->min; 45 | } 46 | if ($range->max !== null) { 47 | $key = $range->exclusiveMax === true ? 'exclusiveMaximum' : 'maximum'; 48 | $validationRules[$key] = $range->max; 49 | } 50 | } 51 | 52 | // Extract MultipleOf constraint 53 | $multipleOf = $property->findAttribute(MultipleOf::class); 54 | if ($multipleOf instanceof MultipleOf && $this->isNumericType($jsonSchemaType)) { 55 | $validationRules['multipleOf'] = $multipleOf->value; 56 | } 57 | 58 | // Extract Items constraint 59 | $items = $property->findAttribute(Items::class); 60 | if ($items instanceof Items && $jsonSchemaType === Type::Array) { 61 | if ($items->min !== null) { 62 | $validationRules['minItems'] = $items->min; 63 | } 64 | if ($items->max !== null) { 65 | $validationRules['maxItems'] = $items->max; 66 | } 67 | if ($items->unique === true) { 68 | $validationRules['uniqueItems'] = true; 69 | } 70 | } 71 | 72 | // Extract Enum constraint 73 | $enum = $property->findAttribute(Enum::class); 74 | if ($enum instanceof Enum) { 75 | $validationRules['enum'] = $enum->values; 76 | } 77 | 78 | return $validationRules; 79 | } 80 | 81 | private function getLengthMinKey(Type $jsonSchemaType): string 82 | { 83 | return match ($jsonSchemaType) { 84 | Type::Array => 'minItems', 85 | default => 'minLength', // fallback 86 | }; 87 | } 88 | 89 | private function getLengthMaxKey(Type $jsonSchemaType): string 90 | { 91 | return match ($jsonSchemaType) { 92 | Type::Array => 'maxItems', 93 | default => 'maxLength', // fallback 94 | }; 95 | } 96 | 97 | private function isNumericType(Type $jsonSchemaType): bool 98 | { 99 | return $jsonSchemaType === Type::Integer || $jsonSchemaType === Type::Number; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Validation/CompositePropertyDataExtractor.php: -------------------------------------------------------------------------------- 1 | $extractors 14 | */ 15 | public function __construct( 16 | private array $extractors = [], 17 | ) {} 18 | 19 | /** 20 | * Create a default instance with commonly used extractors. 21 | */ 22 | public static function createDefault(): self 23 | { 24 | return new self([ 25 | new PhpDocValidationConstraintExtractor(), 26 | new AttributeConstraintExtractor(), 27 | new AdditionalPropertiesExtractor(), // Add the new extractor 28 | ]); 29 | } 30 | 31 | /** 32 | * Add an extractor to the composite. 33 | */ 34 | public function withExtractor(PropertyDataExtractorInterface $extractor): self 35 | { 36 | return new self([...$this->extractors, $extractor]); 37 | } 38 | 39 | public function extractValidationRules(PropertyInterface $property, Type $jsonSchemaType): array 40 | { 41 | $allValidationRules = []; 42 | 43 | foreach ($this->extractors as $extractor) { 44 | $validationRules = $extractor->extractValidationRules($property, $jsonSchemaType); 45 | $allValidationRules = \array_merge($allValidationRules, $validationRules); 46 | } 47 | 48 | return $allValidationRules; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Validation/Constraint/AbstractConstraint.php: -------------------------------------------------------------------------------- 1 | type; 22 | } 23 | 24 | public function getValue(): mixed 25 | { 26 | return $this->value; 27 | } 28 | 29 | protected function isApplicable(string $jsonSchemaType): bool 30 | { 31 | return match ($this->type) { 32 | 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int' => $jsonSchemaType === 'integer', 33 | 'int-range' => $jsonSchemaType === 'integer', 34 | 'non-empty-string', 'numeric-string', 'class-string' => $jsonSchemaType === 'string', 35 | 'non-empty-array', 'array-shape' => $jsonSchemaType === 'array', 36 | default => false, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Validation/Constraint/ArrayConstraint.php: -------------------------------------------------------------------------------- 1 | type) { 15 | 'non-empty-array', 'non-empty-list' => ['minItems' => 1], 16 | 'array-shape' => $this->parseArrayShape(), 17 | default => [], 18 | }; 19 | } 20 | 21 | private function parseArrayShape(): array 22 | { 23 | if (!\is_array($this->value)) { 24 | return []; 25 | } 26 | 27 | $properties = []; 28 | $required = []; 29 | 30 | foreach ($this->value as $key => $spec) { 31 | $isOptional = \str_ends_with($key, '?'); 32 | $cleanKey = $isOptional ? \rtrim($key, '?') : $key; 33 | 34 | if (!$isOptional) { 35 | $required[] = $cleanKey; 36 | } 37 | 38 | // Basic type mapping for shaped array elements 39 | $properties[$cleanKey] = match ($spec) { 40 | 'string' => ['type' => 'string'], 41 | 'int', 'integer' => ['type' => 'integer'], 42 | 'float', 'number' => ['type' => 'number'], 43 | 'bool', 'boolean' => ['type' => 'boolean'], 44 | default => ['type' => 'string'], // fallback 45 | }; 46 | } 47 | 48 | $schema = [ 49 | 'type' => 'object', 50 | 'properties' => $properties, 51 | 'additionalProperties' => false, // Shaped arrays are strict 52 | ]; 53 | 54 | if ($required !== []) { 55 | $schema['required'] = $required; 56 | } 57 | 58 | return $schema; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Validation/Constraint/NumericConstraint.php: -------------------------------------------------------------------------------- 1 | type) { 15 | 'positive-int' => ['minimum' => 1], 16 | 'negative-int' => ['maximum' => -1], 17 | 'non-positive-int' => ['maximum' => 0], 18 | 'non-negative-int' => ['minimum' => 0], 19 | 'int-range' => $this->parseIntRange(), 20 | default => [], 21 | }; 22 | } 23 | 24 | private function parseIntRange(): array 25 | { 26 | if (!\is_array($this->value) || \count($this->value) !== 2) { 27 | return []; 28 | } 29 | 30 | [$min, $max] = $this->value; 31 | $schema = []; 32 | 33 | if ($min !== 'min' && \is_numeric($min)) { 34 | $schema['minimum'] = (int) $min; 35 | } 36 | 37 | if ($max !== 'max' && \is_numeric($max)) { 38 | $schema['maximum'] = (int) $max; 39 | } 40 | 41 | return $schema; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Validation/Constraint/StringConstraint.php: -------------------------------------------------------------------------------- 1 | type) { 15 | 'non-empty-string' => ['minLength' => 1], 16 | 'numeric-string' => ['pattern' => '^[0-9]*\.?[0-9]+$'], 17 | 'class-string' => [ 18 | 'pattern' => '^[a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff\\\\]*$', 19 | 'description' => 'Must be a valid PHP class name', 20 | ], 21 | default => [], 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Validation/ConstraintMapper.php: -------------------------------------------------------------------------------- 1 | createConstraintObject($constraint); 25 | } elseif (\is_array($constraint)) { 26 | $constraintObj = $this->createConstraintObjectFromArray($constraint); 27 | } else { 28 | continue; 29 | } 30 | 31 | if ($constraintObj && $this->isConstraintApplicable($constraintObj, $jsonSchemaType)) { 32 | $rules = $constraintObj->toJsonSchema(); 33 | $validationRules = \array_merge($validationRules, $rules); 34 | } 35 | } 36 | 37 | return $validationRules; 38 | } 39 | 40 | private function createConstraintObject(string $constraint): ?AbstractConstraint 41 | { 42 | return match (true) { 43 | \in_array($constraint, ['positive-int', 'negative-int', 'non-positive-int', 'non-negative-int'], true) => 44 | new NumericConstraint($constraint), 45 | \in_array($constraint, ['non-empty-string', 'numeric-string', 'class-string'], true) => 46 | new StringConstraint($constraint), 47 | \in_array($constraint, ['non-empty-array', 'non-empty-list'], true) => 48 | new ArrayConstraint($constraint), 49 | default => null, 50 | }; 51 | } 52 | 53 | private function createConstraintObjectFromArray(array $constraint): ?AbstractConstraint 54 | { 55 | $type = \array_key_first($constraint); 56 | $value = $type !== null ? $constraint[$type] : null; 57 | 58 | return match ($type) { 59 | 'int-range' => new NumericConstraint($type, $value), 60 | 'array-shape' => new ArrayConstraint($type, $value), 61 | default => null, 62 | }; 63 | } 64 | 65 | private function isConstraintApplicable(AbstractConstraint $constraint, Type $jsonSchemaType): bool 66 | { 67 | return match ($constraint->getType()) { 68 | 'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'int-range' => 69 | $jsonSchemaType === Type::Integer, 70 | 'non-empty-string', 'numeric-string', 'class-string' => 71 | $jsonSchemaType === Type::String, 72 | 'non-empty-array', 'non-empty-list', 'array-shape' => 73 | $jsonSchemaType === Type::Array, 74 | default => false, 75 | }; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Validation/DocBlockParser.php: -------------------------------------------------------------------------------- 1 | ]+)>/'; 14 | private const string ARRAY_SHAPE_PATTERN = '/array\{([^}]+)\}/'; 15 | 16 | public function parseDocComment(string $docComment): array 17 | { 18 | $constraints = []; 19 | 20 | // Extract @var annotation - now captures everything until */ or newline 21 | if (\preg_match(self::VAR_PATTERN, $docComment, $matches)) { 22 | $typeAnnotation = \trim($matches[1]); 23 | $typeAnnotation = \rtrim($typeAnnotation, ' */'); // Clean up trailing */ 24 | $constraints = \array_merge($constraints, $this->parseTypeAnnotation($typeAnnotation)); 25 | } 26 | 27 | return $constraints; 28 | } 29 | 30 | private function parseTypeAnnotation(string $type): array 31 | { 32 | $constraints = []; 33 | 34 | // Handle simple constraints 35 | $simpleConstraints = [ 36 | 'positive-int', 37 | 'negative-int', 38 | 'non-positive-int', 39 | 'non-negative-int', 40 | 'non-empty-string', 41 | 'numeric-string', 42 | 'class-string', 43 | 'non-empty-array', 44 | 'non-empty-list', 45 | ]; 46 | 47 | foreach ($simpleConstraints as $constraint) { 48 | if (\str_contains($type, $constraint)) { 49 | $constraints[] = $constraint; 50 | } 51 | } 52 | 53 | // Handle int ranges like int<-42, 1337> 54 | if (\preg_match(self::INT_RANGE_PATTERN, $type, $matches)) { 55 | $range = $this->parseIntRange($matches[1]); 56 | if ($range !== null) { 57 | $constraints[] = ['int-range' => $range]; 58 | } 59 | } 60 | 61 | // Handle array shapes like array{foo: string, bar: int} 62 | if (\preg_match(self::ARRAY_SHAPE_PATTERN, $type, $matches)) { 63 | $shape = $this->parseArrayShape($matches[1]); 64 | if ($shape !== []) { 65 | $constraints[] = ['array-shape' => $shape]; 66 | } 67 | } 68 | 69 | return $constraints; 70 | } 71 | 72 | private function parseIntRange(string $rangeStr): ?array 73 | { 74 | $parts = \array_map('trim', \explode(',', $rangeStr)); 75 | 76 | if (\count($parts) !== 2) { 77 | return null; 78 | } 79 | 80 | return [$parts[0], $parts[1]]; 81 | } 82 | 83 | private function parseArrayShape(string $shapeStr): array 84 | { 85 | $shape = []; 86 | $elements = \array_map(\trim(...), \explode(',', $shapeStr)); 87 | 88 | foreach ($elements as $element) { 89 | if (\str_contains($element, ':')) { 90 | /** @psalm-suppress PossiblyUndefinedArrayOffset */ 91 | [$key, $valueType] = \array_map(\trim(...), \explode(':', $element, 2)); 92 | $shape[$key] = $valueType; 93 | } 94 | } 95 | 96 | return $shape; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Validation/PhpDocValidationConstraintExtractor.php: -------------------------------------------------------------------------------- 1 | getPropertyDocComment($property); 20 | 21 | if ($docComment === null) { 22 | return []; 23 | } 24 | 25 | $constraints = $this->docBlockParser->parseDocComment($docComment); 26 | 27 | return $this->constraintMapper->mapConstraintsToJsonSchema($constraints, $jsonSchemaType); 28 | } 29 | 30 | private function getPropertyDocComment(PropertyInterface $property): ?string 31 | { 32 | // We need to access the ReflectionProperty to get doc comment 33 | // This requires extending PropertyInterface or accessing it differently 34 | 35 | // For now, we'll use reflection to get the property's doc comment 36 | // This is a bit hacky but necessary without changing the existing interface 37 | 38 | $reflection = new \ReflectionClass($property); 39 | $propertyReflection = null; 40 | 41 | // Try to get the ReflectionProperty from the Property object 42 | try { 43 | $propertyField = $reflection->getProperty('property'); 44 | $propertyReflection = $propertyField->getValue($property); 45 | } catch (\ReflectionException) { 46 | return null; 47 | } 48 | 49 | if (!$propertyReflection instanceof \ReflectionProperty) { 50 | return null; 51 | } 52 | 53 | $docComment = $propertyReflection->getDocComment(); 54 | return $docComment === false ? null : $docComment; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Validation/PropertyDataExtractorInterface.php: -------------------------------------------------------------------------------- 1 | Array of validation rules 18 | */ 19 | public function extractValidationRules(PropertyInterface $property, Type $jsonSchemaType): array; 20 | } 21 | --------------------------------------------------------------------------------