Skip to content

Commit 1008a24

Browse files
author
Sergey Lavrienya
committed
Added support for PHP union types in schema generation
1 parent 1bb7d71 commit 1008a24

25 files changed

+903
-471
lines changed

README.md

Lines changed: 207 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,36 @@ Example array output:
107107
'description' => [
108108
'title' => 'Description',
109109
'description' => 'The description of the movie',
110-
'type' => ['string', 'null'],
110+
'oneOf' => [
111+
['type' => 'null'],
112+
['type' => 'string'],
113+
],
111114
],
112-
'director' => [
113-
'type' => ['string', 'null'],
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-
'type' => ['string', 'null']
119-
'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'],
124+
'oneOf' => [
125+
[
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+
],
138+
],
139+
],
120140
],
121141
],
122142
'required' => [
@@ -142,7 +162,7 @@ final class Actor
142162
/**
143163
* @var array<Movie>
144164
*/
145-
public readonly array $movies = [],
165+
public readonly ?array $movies = null,
146166
public readonly ?Movie $bestMovie = null;
147167
) {
148168
}
@@ -175,21 +195,27 @@ Example array output:
175195
'type' => 'string',
176196
],
177197
'movies' => [
178-
'type' => 'array',
179-
'items' => [
180-
'$ref' => '#/definitions/Movie',
198+
'oneOf' => [
199+
[
200+
'type' => 'null',
201+
],
202+
[
203+
'type' => 'array',
204+
'items' => [
205+
'$ref' => '#/definitions/Movie',
206+
],
207+
],
181208
],
182-
'default' => [],
183209
],
184210
'bestMovie' => [
185211
'title' => 'Best Movie',
186212
'description' => 'The best movie of the actor',
187213
'oneOf' => [
188214
[
189-
'$ref' => '#/definitions/Movie',
215+
'type' => 'null',
190216
],
191217
[
192-
'type' => 'null',
218+
'$ref' => '#/definitions/Movie',
193219
],
194220
],
195221
],
@@ -215,15 +241,24 @@ Example array output:
215241
'description' => [
216242
'title' => 'Description',
217243
'description' => 'The description of the movie',
218-
'type' => ['string', 'null'],
244+
'oneOf' => [
245+
['type' => 'null'],
246+
['type' => 'string'],
247+
],
219248
],
220249
'director' => [
221-
'type' => ['string', 'null'],
250+
'oneOf' => [
251+
['type' => 'null'],
252+
['type' => 'string'],
253+
],
222254
],
223255
'releaseStatus' => [
224256
'title' => 'Release Status',
225257
'description' => 'The release status of the movie',
226-
'type' => ['string', 'null']
258+
'oneOf' => [
259+
['type' => 'null'],
260+
['type' => 'string'],
261+
],
227262
'enum' => ['Released', 'Rumored', 'Post Production', 'In Production', 'Planned', 'Canceled'],
228263
],
229264
],
@@ -236,6 +271,163 @@ Example array output:
236271
];
237272
```
238273

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+
],
323+
],
324+
],
325+
],
326+
'definitions' => [
327+
'Movie' => [/* ... */],
328+
'Series' => [/* ... */],
329+
],
330+
];
331+
```
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.
430+
239431
## Testing
240432

241433
```bash

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.0 <7.3",
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
},

src/Generator.php

Lines changed: 25 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
use Spiral\JsonSchemaGenerator\Parser\Parser;
1010
use Spiral\JsonSchemaGenerator\Parser\ParserInterface;
1111
use Spiral\JsonSchemaGenerator\Parser\PropertyInterface;
12-
use Spiral\JsonSchemaGenerator\Parser\TypeInterface;
12+
use Spiral\JsonSchemaGenerator\Parser\SimpleType;
13+
use Spiral\JsonSchemaGenerator\Parser\Type;
1314
use Spiral\JsonSchemaGenerator\Schema\Definition;
1415
use Spiral\JsonSchemaGenerator\Schema\Property;
16+
use Spiral\JsonSchemaGenerator\Schema\PropertyType;
1517

1618
class Generator implements GeneratorInterface
1719
{
@@ -119,43 +121,28 @@ protected function generateProperty(PropertyInterface $property): ?Property
119121

120122
$type = $property->getType();
121123

122-
$options = [];
123-
if ($property->isCollection()) {
124-
$options = \array_map(
125-
static fn(TypeInterface $type) => $type->getName(),
126-
$property->getCollectionValueTypes(),
127-
);
128-
}
129-
130-
$required = $default === null && !$type->allowsNull();
131-
if ($type->isBuiltin()) {
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-
);
143-
}
124+
return new Property(
125+
types: $this->extractPropertyTypes($type),
126+
title: $title,
127+
description: $description,
128+
required: $default === null && !$type->allowsNull(),
129+
default: $default,
130+
format: $format,
131+
);
132+
}
144133

145-
// Class
146-
$class = $type->getName();
147-
148-
return \is_string($class) && \class_exists($class)
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-
)
159-
: null;
134+
/**
135+
* @return list<PropertyType>
136+
*/
137+
private function extractPropertyTypes(Type $type): array
138+
{
139+
return \array_map(static fn(SimpleType $simpleType) => new PropertyType(
140+
type: $simpleType->getName(),
141+
enum: $simpleType->getEnumValues(),
142+
collectionTypes: $simpleType->isCollection() ? \array_map(static fn(SimpleType $collectionSimpleType) => new PropertyType(
143+
type: $collectionSimpleType->getName(),
144+
enum: $collectionSimpleType->getEnumValues(),
145+
), $simpleType->getCollectionType()?->types ?? []) : null,
146+
), $type->types);
160147
}
161148
}

0 commit comments

Comments
 (0)