Skip to content

Commit df03892

Browse files
authored
Merge pull request #13 from oddssoft/1.x
Add nullable enum support, fix Symfony 7.3 Psalm warnings, and correct class property definitions
2 parents ab37d9e + 1008a24 commit df03892

26 files changed

+932
-511
lines changed

README.md

Lines changed: 215 additions & 42 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,17 +107,34 @@ Example array output:
107107
'description' => [
108108
'title' => 'Description',
109109
'description' => 'The description of the movie',
110-
'type' => 'string',
110+
'oneOf' => [
111+
['type' => 'null'],
112+
['type' => 'string'],
113+
],
111114
],
112-
'director' => [
113-
'type' => 'string',
115+
'director' => [
116+
'oneOf' => [
117+
['type' => 'null'],
118+
['type' => 'string'],
119+
],
114120
],
115121
'releaseStatus' => [
116122
'title' => 'Release Status',
117123
'description' => 'The release status of the movie',
118-
'allOf' => [
124+
'oneOf' => [
119125
[
120-
'$ref' => '#/definitions/ReleaseStatus',
126+
'type' => 'null',
127+
],
128+
[
129+
'type' => 'string',
130+
'enum' => [
131+
'Released',
132+
'Rumored',
133+
'Post Production',
134+
'In Production',
135+
'Planned',
136+
'Canceled',
137+
],
121138
],
122139
],
123140
],
@@ -126,20 +143,6 @@ Example array output:
126143
'title',
127144
'year',
128145
],
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-
],
143146
];
144147
```
145148

@@ -159,7 +162,8 @@ final class Actor
159162
/**
160163
* @var array<Movie>
161164
*/
162-
public readonly array $movies = [],
165+
public readonly ?array $movies = null,
166+
public readonly ?Movie $bestMovie = null;
163167
) {
164168
}
165169
}
@@ -191,11 +195,29 @@ Example array output:
191195
'type' => 'string',
192196
],
193197
'movies' => [
194-
'type' => 'array',
195-
'items' => [
196-
'$ref' => '#/definitions/Movie',
198+
'oneOf' => [
199+
[
200+
'type' => 'null',
201+
],
202+
[
203+
'type' => 'array',
204+
'items' => [
205+
'$ref' => '#/definitions/Movie',
206+
],
207+
],
208+
],
209+
],
210+
'bestMovie' => [
211+
'title' => 'Best Movie',
212+
'description' => 'The best movie of the actor',
213+
'oneOf' => [
214+
[
215+
'type' => 'null',
216+
],
217+
[
218+
'$ref' => '#/definitions/Movie',
219+
],
197220
],
198-
'default' => [],
199221
],
200222
],
201223
'required' => [
@@ -219,41 +241,192 @@ Example array output:
219241
'description' => [
220242
'title' => 'Description',
221243
'description' => 'The description of the movie',
222-
'type' => 'string',
244+
'oneOf' => [
245+
['type' => 'null'],
246+
['type' => 'string'],
247+
],
223248
],
224249
'director' => [
225-
'type' => 'string',
250+
'oneOf' => [
251+
['type' => 'null'],
252+
['type' => 'string'],
253+
],
226254
],
227255
'releaseStatus' => [
228256
'title' => 'Release Status',
229257
'description' => 'The release status of the movie',
230-
'allOf' => [
231-
[
232-
'$ref' => '#/definitions/ReleaseStatus',
233-
],
258+
'oneOf' => [
259+
['type' => 'null'],
260+
['type' => 'string'],
234261
],
262+
'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'],
235263
],
236264
],
237265
'required' => [
238266
'title',
239267
'year',
240268
],
241269
],
242-
'ReleaseStatus' => [
243-
'title' => 'ReleaseStatus',
244-
'type' => 'string',
245-
'enum' => [
246-
'Released',
247-
'Rumored',
248-
'Post Production',
249-
'In Production',
250-
'Planned',
251-
'Canceled',
270+
],
271+
];
272+
```
273+
274+
## Polymorphic Arrays (anyOf)
275+
The generator also supports arrays that contain different types of DTOs using PHPDoc annotations like **@var list<Movie|Series>**.
276+
For example, if an actor can have a filmography that includes both movies and TV series, you can define it like this:
277+
```php
278+
namespace App\DTO;
279+
280+
use Spiral\JsonSchemaGenerator\Attribute\Field;
281+
282+
final class Actor
283+
{
284+
public function __construct(
285+
public readonly string $name,
286+
public readonly int $age,
287+
288+
#[Field(title: 'Biography', description: 'The biography of the actor')]
289+
public readonly ?string $bio = null,
290+
291+
/**
292+
* @var list<Movie|Series>|null
293+
*/
294+
#[Field(title: 'Filmography', description: 'List of movies and series featuring the actor')]
295+
public readonly ?array $filmography = null,
296+
297+
#[Field(title: 'Best Movie', description: 'The best movie of the actor')]
298+
public readonly ?Movie $bestMovie = null,
299+
300+
#[Field(title: 'Best Series', description: 'The most prominent series of the actor')]
301+
public readonly ?Series $bestSeries = null,
302+
) {}
303+
}
304+
```
305+
The generated schema will reflect this with an anyOf definition in the items section:
306+
```php
307+
[
308+
'properties' => [
309+
'filmography' => [
310+
'title' => 'Filmography',
311+
'description' => 'List of movies and series featuring the actor',
312+
'oneOf' => [
313+
['type' => 'null'],
314+
[
315+
'type' => 'array',
316+
'items' => [
317+
'anyOf' => [
318+
['$ref' => '#/definitions/Movie'],
319+
['$ref' => '#/definitions/Series'],
320+
],
321+
],
322+
],
252323
],
253-
]
324+
],
325+
],
326+
'definitions' => [
327+
'Movie' => [/* ... */],
328+
'Series' => [/* ... */],
254329
],
255330
];
256331
```
332+
## Example DTO: Series
333+
Here's what the Series class might look like:
334+
```php
335+
namespace App\DTO;
336+
337+
use Spiral\JsonSchemaGenerator\Attribute\Field;
338+
use Spiral\JsonSchemaGenerator\Attribute\Format;
339+
340+
final class Series
341+
{
342+
public function __construct(
343+
#[Field(title: 'Title', description: 'The title of the series')]
344+
public readonly string $title,
345+
346+
#[Field(title: 'First Air Year', description: 'The year the series first aired')]
347+
public readonly int $firstAirYear,
348+
349+
#[Field(title: 'Description', description: 'The description of the series')]
350+
public readonly ?string $description = null,
351+
352+
#[Field(title: 'Creator', description: 'The creator or showrunner of the series')]
353+
public readonly ?string $creator = null,
354+
355+
#[Field(title: 'Series Status', description: 'The current status of the series')]
356+
public readonly ?SeriesStatus $status = null,
357+
358+
#[Field(title: 'First Air Date', description: 'The original release date of the series', format: Format::Date)]
359+
public readonly ?string $firstAirDate = null,
360+
361+
#[Field(title: 'Last Air Date', description: 'The most recent air date of the series', format: Format::Date)]
362+
public readonly ?string $lastAirDate = null,
363+
364+
#[Field(title: 'Seasons', description: 'Number of seasons released')]
365+
public readonly ?int $seasons = null,
366+
) {}
367+
}
368+
```
369+
> **Note**
370+
> When using polymorphic arrays, make sure all referenced DTOs (e.g., Movie, Series)
371+
> are also annotated properly so their definitions can be generated correctly.
372+
373+
## Union Types
374+
The JSON Schema Generator supports native PHP union types (introduced in PHP 8.0), including nullable and multi-type definitions.
375+
Here's an example DTO using union types:
376+
```php
377+
namespace App\DTO;
378+
379+
use Spiral\JsonSchemaGenerator\Attribute\Field;
380+
381+
final class FlexibleValue
382+
{
383+
public function __construct(
384+
#[Field(title: 'Value', description: 'Can be either string or integer')]
385+
public readonly string|int $value,
386+
387+
#[Field(title: 'Optional Flag', description: 'Boolean or null')]
388+
public readonly bool|null $flag = null,
389+
390+
#[Field(title: 'Flexible Field', description: 'Can be string, int, or null')]
391+
public readonly string|int|null $flex = null,
392+
) {}
393+
}
394+
```
395+
The generated schema will include a `oneOf` section to reflect the union types:
396+
```php
397+
[
398+
'properties' => [
399+
'value' => [
400+
'title' => 'Value',
401+
'description' => 'Can be either string or integer',
402+
'oneOf' => [
403+
['type' => 'string'],
404+
['type' => 'integer'],
405+
],
406+
],
407+
'flag' => [
408+
'title' => 'Optional Flag',
409+
'description' => 'Boolean or null',
410+
'oneOf' => [
411+
['type' => 'null'],
412+
['type' => 'boolean'],
413+
],
414+
],
415+
'flex' => [
416+
'title' => 'Flexible Field',
417+
'description' => 'Can be string, int, or null',
418+
'oneOf' => [
419+
['type' => 'null'],
420+
['type' => 'string'],
421+
['type' => 'integer'],
422+
],
423+
],
424+
],
425+
'required' => ['value'],
426+
]
427+
```
428+
> **Note**
429+
> All supported types are automatically resolved from native PHP type declarations and reflected in the JSON Schema output using oneOf.
257430
258431
## Testing
259432

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",
2828
"phpstan/phpdoc-parser": "^1.33 | ^2.1",
2929
"phpdocumentor/reflection-docblock": "^5.3"
3030
},

0 commit comments

Comments
 (0)