Skip to content

Commit c851480

Browse files
author
Sergey Lavrienya
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 c851480

File tree

13 files changed

+150
-162
lines changed

13 files changed

+150
-162
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
```

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
],
2525
"require": {
2626
"php": ">=8.1",
27-
"symfony/property-info": "^6.4.18 || ^7.2",
27+
"symfony/property-info": "^6.4.18 || ^7.2.0 <7.3",
2828
"phpstan/phpdoc-parser": "^1.33 | ^2.1",
2929
"phpdocumentor/reflection-docblock": "^5.3"
3030
},

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: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ public function getProperties(): array
8888

8989
$properties[] = new Property(
9090
property: $property,
91-
type: new Type(name: $type->getName(), builtin: $type->isBuiltin(), nullable: $type->allowsNull()),
91+
type: new Type(
92+
name: $this->getTypeName($type),
93+
builtin: $this->getTypeBuildIn($type),
94+
nullable: $type->allowsNull(),
95+
enum: $this->getEnumValues($type),
96+
),
9297
hasDefaultValue: $this->hasPropertyDefaultValue($property),
9398
defaultValue: $this->getPropertyDefaultValue($property),
9499
collectionValueTypes: $this->getPropertyCollectionTypes($property->getName()),
@@ -103,21 +108,47 @@ public function isEnum(): bool
103108
return $this->class->isEnum();
104109
}
105110

106-
public function getEnumValues(): array
111+
private function getEnumValues(\ReflectionNamedType $type): ?array
107112
{
108-
if (!$this->isEnum()) {
109-
throw new GeneratorException(\sprintf('Class `%s` is not an enum.', $this->class->getName()));
113+
if (!\is_subclass_of($type->getName(), \BackedEnum::class)) {
114+
return null;
110115
}
111116

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

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

120-
return $values;
151+
return $backingType->getName();
121152
}
122153

123154
/**

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
}

src/Schema/Property.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function __construct(
2222
public readonly bool $required = false,
2323
public readonly bool $allowsNull = false,
2424
public readonly mixed $default = null,
25+
public ?array $enum = null,
2526
public readonly ?Format $format = null,
2627
) {
2728
if (\is_string($this->type) && !\class_exists($this->type)) {
@@ -58,11 +59,21 @@ public function jsonSerialize(): array
5859

5960
if (\is_string($this->type)) {
6061
// this is nested class
61-
$property['allOf'][] = ['$ref' => (new Reference($this->type))->jsonSerialize()];
62+
if ($this->allowsNull) {
63+
$property['oneOf'][] = ['$ref' => (new Reference($this->type))->jsonSerialize()];
64+
$property['oneOf'][] = ['type' => Type::Null->value];
65+
return $property;
66+
}
67+
$property['$ref'] = (new Reference($this->type))->jsonSerialize();
68+
6269
return $property;
6370
}
6471

65-
$property['type'] = $this->allowsNull ? [$this->type->value, Type::Null] : $this->type->value;
72+
$property['type'] = $this->allowsNull ? [$this->type->value, Type::Null->value] : $this->type->value;
73+
74+
if ($this->enum !== null) {
75+
$property['enum'] = $this->enum;
76+
}
6677

6778
if ($this->type === Type::Array) {
6879
if (\count($this->options) === 1) {

0 commit comments

Comments
 (0)