Skip to content

Commit 1e7f710

Browse files
author
Сергей Лавриеня
committed
Improved enum and object property representation in JSON Schema
- Changed enum generation from $ref to inline enum with "type": "string" and explicit "enum" values - Added proper nullable support for enums using ["string", "null"] - Replaced invalid allOf usage for object types with correct $ref - When nullable object, used oneOf with $ref and null type
1 parent da48dba commit 1e7f710

File tree

12 files changed

+204
-180
lines changed

12 files changed

+204
-180
lines changed

README.md

Lines changed: 22 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![Total Downloads](https://poser.pugx.org/spiral/json-schema-generator/downloads)](https://packagist.org/packages/spiral/json-schema-generator)
88
[![psalm-level](https://shepherd.dev/github/spiral/json-schema-generator/level.svg)](https://shepherd.dev/github/spiral/json-schema-generator)
99

10-
The JSON Schema Generator is a PHP package that simplifies the generation of [JSON schemas](https://json-schema.org/) from Data Transfer Object (DTO) classes.
10+
The JSON Schema Generator is a PHP package that simplifies the generation of [JSON schemas](https://json-schema.org/) from Data Transfer Object (DTO) classes.
1111
It supports PHP enumerations and generic type annotations for arrays and provides an attribute for specifying title, description, and default value.
1212

1313
Main use case - structured output definition for LLMs.
@@ -107,39 +107,22 @@ Example array output:
107107
'description' => [
108108
'title' => 'Description',
109109
'description' => 'The description of the movie',
110-
'type' => 'string',
110+
'type' => ['string', 'null'],
111111
],
112112
'director' => [
113-
'type' => 'string',
113+
'type' => ['string', 'null'],
114114
],
115115
'releaseStatus' => [
116116
'title' => 'Release Status',
117117
'description' => 'The release status of the movie',
118-
'allOf' => [
119-
[
120-
'$ref' => '#/definitions/ReleaseStatus',
121-
],
122-
],
118+
'type' => ['string', 'null']
119+
'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'],
123120
],
124121
],
125122
'required' => [
126123
'title',
127124
'year',
128125
],
129-
'definitions' => [
130-
'ReleaseStatus' => [
131-
'title' => 'ReleaseStatus',
132-
'type' => 'string',
133-
'enum' => [
134-
'Released',
135-
'Rumored',
136-
'Post Production',
137-
'In Production',
138-
'Planned',
139-
'Canceled',
140-
],
141-
],
142-
],
143126
];
144127
```
145128

@@ -160,6 +143,7 @@ final class Actor
160143
* @var array<Movie>
161144
*/
162145
public readonly array $movies = [],
146+
public readonly ?Movie $bestMovie = null;
163147
) {
164148
}
165149
}
@@ -197,6 +181,18 @@ Example array output:
197181
],
198182
'default' => [],
199183
],
184+
'bestMovie' => [
185+
'title' => 'Best Movie',
186+
'description' => 'The best movie of the actor',
187+
'oneOf' => [
188+
[
189+
'$ref' => '#/definitions/Movie',
190+
],
191+
[
192+
'type' => 'null',
193+
],
194+
],
195+
],
200196
],
201197
'required' => [
202198
'name',
@@ -219,38 +215,23 @@ Example array output:
219215
'description' => [
220216
'title' => 'Description',
221217
'description' => 'The description of the movie',
222-
'type' => 'string',
218+
'type' => ['string', 'null'],
223219
],
224220
'director' => [
225-
'type' => 'string',
221+
'type' => ['string', 'null'],
226222
],
227223
'releaseStatus' => [
228224
'title' => 'Release Status',
229225
'description' => 'The release status of the movie',
230-
'allOf' => [
231-
[
232-
'$ref' => '#/definitions/ReleaseStatus',
233-
],
234-
],
226+
'type' => ['string', 'null']
227+
'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'],
235228
],
236229
],
237230
'required' => [
238231
'title',
239232
'year',
240233
],
241234
],
242-
'ReleaseStatus' => [
243-
'title' => 'ReleaseStatus',
244-
'type' => 'string',
245-
'enum' => [
246-
'Released',
247-
'Rumored',
248-
'Post Production',
249-
'In Production',
250-
'Planned',
251-
'Canceled',
252-
],
253-
]
254235
],
255236
];
256237
```

src/Generator.php

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,6 @@ public function generate(string|\ReflectionClass $class): Schema
8383
protected function generateDefinition(ClassParserInterface $class, array &$dependencies = []): ?Definition
8484
{
8585
$properties = [];
86-
if ($class->isEnum()) {
87-
return new Definition(
88-
type: $class->getName(),
89-
options: $class->getEnumValues(),
90-
title: $class->getShortName(),
91-
);
92-
}
93-
9486
// class properties
9587
foreach ($class->getProperties() as $property) {
9688
$psc = $this->generateProperty($property);
@@ -137,14 +129,33 @@ protected function generateProperty(PropertyInterface $property): ?Property
137129

138130
$required = $default === null && !$type->allowsNull();
139131
if ($type->isBuiltin()) {
140-
return new Property($type->getName(), $options, $title, $description, $required, $type->allowsNull(), $default, $format);
132+
return new Property(
133+
type: $type->getName(),
134+
options: $options,
135+
title: $title,
136+
description: $description,
137+
required: $required,
138+
allowsNull: $type->allowsNull(),
139+
default: $default,
140+
enum: $type->getEnumValues(),
141+
format: $format,
142+
);
141143
}
142144

143-
// Class or enum
145+
// Class
144146
$class = $type->getName();
145147

146148
return \is_string($class) && \class_exists($class)
147-
? new Property($class, [], $title, $description, $required, $type->allowsNull(), $default, $format)
149+
? new Property(
150+
type: $class,
151+
options: [],
152+
title: $title,
153+
description: $description,
154+
required: $required,
155+
allowsNull: $type->allowsNull(),
156+
default: $default,
157+
format: $format,
158+
)
148159
: null;
149160
}
150161
}

src/Parser/ClassParser.php

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
1212
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
1313
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
14+
use Symfony\Component\TypeInfo\Type as SymfonyType;
15+
use Symfony\Component\TypeInfo\Type\BuiltinType;
16+
use Symfony\Component\TypeInfo\Type\CollectionType;
17+
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
18+
use Symfony\Component\TypeInfo\Type\NullableType;
19+
use Symfony\Component\TypeInfo\Type\ObjectType;
1420

1521
/**
1622
* @internal
@@ -88,7 +94,12 @@ public function getProperties(): array
8894

8995
$properties[] = new Property(
9096
property: $property,
91-
type: new Type(name: $type->getName(), builtin: $type->isBuiltin(), nullable: $type->allowsNull()),
97+
type: new Type(
98+
name: $this->getTypeName($type),
99+
builtin: $this->getTypeBuildIn($type),
100+
nullable: $type->allowsNull(),
101+
enum: $this->getEnumValues($type),
102+
),
92103
hasDefaultValue: $this->hasPropertyDefaultValue($property),
93104
defaultValue: $this->getPropertyDefaultValue($property),
94105
collectionValueTypes: $this->getPropertyCollectionTypes($property->getName()),
@@ -103,21 +114,47 @@ public function isEnum(): bool
103114
return $this->class->isEnum();
104115
}
105116

106-
public function getEnumValues(): array
117+
private function getEnumValues(\ReflectionNamedType $type): ?array
107118
{
108-
if (!$this->isEnum()) {
109-
throw new GeneratorException(\sprintf('Class `%s` is not an enum.', $this->class->getName()));
119+
if (!\is_subclass_of($type->getName(), \BackedEnum::class)) {
120+
return null;
110121
}
111122

112-
$values = [];
113-
foreach ($this->class->getReflectionConstants() as $constant) {
114-
$value = $constant->getValue();
115-
\assert($value instanceof \BackedEnum);
123+
$reflectionEnum = new \ReflectionEnum($type->getName());
116124

117-
$values[] = $value->value;
125+
return \array_map(
126+
static fn(\ReflectionEnumUnitCase $case): int|string => $case->getValue()->value,
127+
$reflectionEnum->getCases(),
128+
);
129+
}
130+
131+
private function getTypeBuildIn(\ReflectionNamedType $type): bool
132+
{
133+
if ($type->isBuiltin() || \is_subclass_of($type->getName(), \BackedEnum::class)) {
134+
return true;
135+
}
136+
137+
return false;
138+
}
139+
140+
/**
141+
* @return non-empty-string
142+
*/
143+
private function getTypeName(\ReflectionNamedType $type): string
144+
{
145+
$typeName = $type->getName();
146+
if ($type->isBuiltin() || !\is_subclass_of($typeName, \BackedEnum::class)) {
147+
return $typeName;
148+
}
149+
150+
$reflection = new \ReflectionEnum($typeName);
151+
$backingType = $reflection->getBackingType();
152+
153+
if (!$backingType instanceof \ReflectionNamedType) {
154+
return $typeName;
118155
}
119156

120-
return $values;
157+
return $backingType->getName();
121158
}
122159

123160
/**
@@ -127,32 +164,62 @@ public function getEnumValues(): array
127164
*/
128165
private function getPropertyCollectionTypes(string $property): array
129166
{
130-
$types = $this->propertyInfo->getTypes($this->class->getName(), $property);
167+
$type = $this->propertyInfo->getType($this->class->getName(), $property);
131168

132-
$collectionTypes = [];
133-
foreach ($types ?? [] as $type) {
134-
if ($type->isCollection()) {
135-
$collectionTypes = [...$type->getCollectionValueTypes(), ...$collectionTypes];
169+
$result = [];
170+
$rawType = $type;
171+
if ($type instanceof NullableType) {
172+
$rawType = $type->getWrappedType();
173+
}
174+
175+
if ($rawType instanceof CollectionType) {
176+
$collectionValueType = $rawType->getCollectionValueType();
177+
if ($collectionValueType instanceof CompositeTypeInterface && !$collectionValueType instanceof NullableType) {
178+
foreach ($collectionValueType->getTypes() as $collectionSubType) {
179+
$type = $this->parseSymfonyType($collectionSubType);
180+
if ($type !== null) {
181+
$result[] = $type;
182+
}
183+
}
184+
} else {
185+
$type = $this->parseSymfonyType($collectionValueType);
186+
if ($type !== null) {
187+
$result[] = $type;
188+
}
136189
}
137190
}
138191

139-
$result = [];
140-
foreach ($collectionTypes as $type) {
141-
/**
142-
* @var non-empty-string $name
143-
*/
144-
$name = $type->getBuiltinType() === SchemaType::Object->value
145-
? $type->getClassName()
146-
: $type->getBuiltinType();
147-
148-
$result[] = new Type(
149-
name: $name,
150-
builtin: $type->getBuiltinType() !== SchemaType::Object->value,
151-
nullable: $type->isNullable(),
192+
return $result;
193+
}
194+
195+
private function parseSymfonyType(SymfonyType $type): ?Type
196+
{
197+
$rawType = $type;
198+
$isNullable = false;
199+
200+
if ($type instanceof NullableType) {
201+
$rawType = $type->getWrappedType();
202+
$isNullable = true;
203+
}
204+
205+
if ($rawType instanceof ObjectType) {
206+
return new Type(
207+
name: $rawType->getClassName(),
208+
builtin: false,
209+
nullable: $isNullable,
152210
);
153211
}
154212

155-
return $result;
213+
// The set of built-in types in Symfony is more extensive and expressive than the limited set of primitive types defined by JSON Schema.
214+
if ($rawType instanceof BuiltinType && SchemaType::tryFrom($rawType->getTypeIdentifier()->value) !== null) {
215+
return new Type(
216+
name: $rawType->getTypeIdentifier()->value,
217+
builtin: true,
218+
nullable: $isNullable,
219+
);
220+
}
221+
222+
return null;
156223
}
157224

158225
private function hasPropertyDefaultValue(\ReflectionProperty $property): bool

src/Parser/ClassParserInterface.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,4 @@ public function getShortName(): string;
2020
* @return array<PropertyInterface>
2121
*/
2222
public function getProperties(): array;
23-
24-
public function isEnum(): bool;
25-
26-
public function getEnumValues(): array;
2723
}

src/Parser/Type.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public function __construct(
2323
string $name,
2424
private readonly bool $builtin,
2525
private readonly bool $nullable,
26+
private readonly ?array $enum = null,
2627
) {
2728
/** @psalm-suppress PropertyTypeCoercion */
2829
$this->name = $this->builtin ? SchemaType::fromBuiltIn($name) : $name;
@@ -45,4 +46,14 @@ public function allowsNull(): bool
4546
{
4647
return $this->nullable;
4748
}
49+
50+
public function isEnum(): bool
51+
{
52+
return $this->enum !== null;
53+
}
54+
55+
public function getEnumValues(): ?array
56+
{
57+
return $this->enum;
58+
}
4859
}

src/Parser/TypeInterface.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ public function getName(): string|SchemaType;
1616
public function isBuiltin(): bool;
1717

1818
public function allowsNull(): bool;
19+
20+
public function isEnum(): bool;
21+
22+
public function getEnumValues(): ?array;
1923
}

0 commit comments

Comments
 (0)