Skip to content
This repository was archived by the owner on Sep 25, 2025. It is now read-only.

Commit 7a715a7

Browse files
committed
This commit adds support for PHP 8.0+ union types (like string|int|bool) in the JSON Schema Generator. Union types are now properly represented in JSON Schema using the oneOf keyword.
1 parent 3eec43e commit 7a715a7

18 files changed

+824
-20
lines changed

context.yaml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,32 @@ documents:
2525
sources:
2626
- type: file
2727
sourcePaths:
28-
- tests
28+
- tests
29+
30+
tools:
31+
- id: run-all-tests
32+
description: "Run all PHPUnit tests for the json-schema-generator"
33+
type: run
34+
commands:
35+
- cmd: composer
36+
args:
37+
- install
38+
workingDir: "./"
39+
- cmd: vendor/bin/phpunit
40+
args:
41+
- "--color=always"
42+
workingDir: "./"
43+
44+
- id: run-union-tests
45+
description: "Run only the union type tests"
46+
type: run
47+
commands:
48+
- cmd: composer
49+
args:
50+
- install
51+
workingDir: "./"
52+
- cmd: vendor/bin/phpunit
53+
args:
54+
- "--color=always"
55+
- "--filter=UnionType"
56+
workingDir: "./"

examples/UnionTypeExample.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Examples;
6+
7+
use Spiral\JsonSchemaGenerator\Attribute\Field;
8+
9+
/**
10+
* Example DTO with union types to demonstrate the oneOf JSON Schema generation.
11+
*/
12+
class UnionTypeExample
13+
{
14+
public function __construct(
15+
#[Field(title: 'String or Integer Value', description: 'A value that can be either a string or an integer')]
16+
public readonly string|int $stringOrInt,
17+
18+
#[Field(title: 'Multiple Types', description: 'A value that can be one of multiple types')]
19+
public readonly string|int|bool|null $multiType = null,
20+
21+
#[Field(title: 'Object Union', description: 'A value that can be one of multiple object types')]
22+
public readonly SimpleObject|ComplexObject|null $objectUnion = null,
23+
) {}
24+
}
25+
26+
/**
27+
* Simple object for union type example.
28+
*/
29+
class SimpleObject
30+
{
31+
public function __construct(
32+
public readonly string $name,
33+
) {}
34+
}
35+
36+
/**
37+
* Complex object for union type example.
38+
*/
39+
class ComplexObject
40+
{
41+
public function __construct(
42+
public readonly string $title,
43+
public readonly int $count,
44+
) {}
45+
}

runAllTests.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
// Simple script to execute all PHPUnit tests
3+
4+
echo "Running all PHPUnit tests...\n";
5+
passthru('vendor/bin/phpunit', $exitCode);
6+
exit($exitCode);

src/Generator.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Spiral\JsonSchemaGenerator\Parser\ParserInterface;
1313
use Spiral\JsonSchemaGenerator\Parser\PropertyInterface;
1414
use Spiral\JsonSchemaGenerator\Parser\TypeInterface;
15+
use Spiral\JsonSchemaGenerator\Parser\UnionType;
1516
use Spiral\JsonSchemaGenerator\Schema\Definition;
1617
use Spiral\JsonSchemaGenerator\Schema\Property;
1718

@@ -197,6 +198,12 @@ protected function generateProperty(PropertyInterface $property): ?Property
197198

198199
$type = $property->getType();
199200

201+
// Handle union types (e.g., string|int|bool)
202+
if ($type instanceof UnionType) {
203+
$required = $default === null && !$type->allowsNull();
204+
return new Property($type, [], $title, $description, $required, $default);
205+
}
206+
200207
$options = [];
201208
if ($property->isCollection()) {
202209
$options = \array_map(

src/Parser/ClassParser.php

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,12 @@ public function getProperties(): array
7878
continue;
7979
}
8080

81-
/**
82-
* @var \ReflectionNamedType|null $type
83-
*/
84-
$type = $property->getType();
85-
if (!$type instanceof \ReflectionNamedType) {
86-
continue;
87-
}
81+
// Parse the type using TypeParser
82+
$typeInterface = TypeParser::fromReflectionType($property->getType());
8883

8984
$properties[] = new Property(
9085
property: $property,
91-
type: new Type(name: $type->getName(), builtin: $type->isBuiltin(), nullable: $type->allowsNull()),
86+
type: $typeInterface,
9287
hasDefaultValue: $this->hasPropertyDefaultValue($property),
9388
defaultValue: $this->getPropertyDefaultValue($property),
9489
collectionValueTypes: $this->getPropertyCollectionTypes($property->getName()),

src/Parser/TypeParser.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Parser;
6+
7+
use Spiral\JsonSchemaGenerator\Exception\InvalidTypeException;
8+
9+
/**
10+
* Parses PHP reflection types into TypeInterface instances.
11+
*
12+
* @internal
13+
*/
14+
final class TypeParser
15+
{
16+
/**
17+
* Parse a PHP reflection type into a TypeInterface.
18+
*/
19+
public static function fromReflectionType(\ReflectionType $reflectionType): TypeInterface
20+
{
21+
if ($reflectionType instanceof \ReflectionUnionType) {
22+
return self::parseUnionType($reflectionType);
23+
}
24+
25+
if ($reflectionType instanceof \ReflectionNamedType) {
26+
return self::parseNamedType($reflectionType);
27+
}
28+
29+
// PHP 8.1+ intersection types are not supported in JSON Schema
30+
if ($reflectionType instanceof \ReflectionIntersectionType) {
31+
throw new InvalidTypeException('Intersection types are not supported in JSON Schema.');
32+
}
33+
34+
throw new InvalidTypeException('Unsupported reflection type: ' . $reflectionType::class);
35+
}
36+
37+
/**
38+
* Parse a union type into a UnionType.
39+
*/
40+
private static function parseUnionType(\ReflectionUnionType $unionType): UnionType
41+
{
42+
$types = [];
43+
foreach ($unionType->getTypes() as $type) {
44+
if ($type instanceof \ReflectionNamedType) {
45+
$types[] = self::parseNamedType($type);
46+
} else {
47+
throw new InvalidTypeException('Nested union or intersection types are not supported.');
48+
}
49+
}
50+
51+
return new UnionType($types);
52+
}
53+
54+
/**
55+
* Parse a named type into a Type.
56+
*/
57+
private static function parseNamedType(\ReflectionNamedType $namedType): Type
58+
{
59+
return new Type(
60+
name: $namedType->getName(),
61+
builtin: $namedType->isBuiltin(),
62+
nullable: $namedType->allowsNull(),
63+
);
64+
}
65+
}

src/Parser/UnionType.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Parser;
6+
7+
use Spiral\JsonSchemaGenerator\Schema\Type as SchemaType;
8+
9+
/**
10+
* Represents a PHP union type (e.g., string|int|null).
11+
*
12+
* @internal
13+
*/
14+
final readonly class UnionType implements TypeInterface
15+
{
16+
/**
17+
* @param array<TypeInterface> $types
18+
*/
19+
public function __construct(private array $types) {}
20+
21+
/**
22+
* Always returns SchemaType::Union for union types.
23+
*/
24+
public function getName(): string|SchemaType
25+
{
26+
return SchemaType::Union;
27+
}
28+
29+
/**
30+
* @return array<TypeInterface>
31+
*/
32+
public function getTypes(): array
33+
{
34+
return $this->types;
35+
}
36+
37+
/**
38+
* Union types are not built-in types.
39+
*/
40+
public function isBuiltin(): bool
41+
{
42+
return false;
43+
}
44+
45+
/**
46+
* Checks if any of the types in the union allows null.
47+
*/
48+
public function allowsNull(): bool
49+
{
50+
foreach ($this->types as $type) {
51+
if ($type->allowsNull()) {
52+
return true;
53+
}
54+
}
55+
56+
return false;
57+
}
58+
}

src/Schema/Property.php

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@
55
namespace Spiral\JsonSchemaGenerator\Schema;
66

77
use Spiral\JsonSchemaGenerator\Exception\InvalidTypeException;
8+
use Spiral\JsonSchemaGenerator\Parser\UnionType;
89

910
final readonly class Property implements \JsonSerializable
1011
{
1112
public PropertyOptions $options;
1213

1314
/**
14-
* @param Type|class-string $type
15+
* @param Type|class-string|UnionType $type
1516
* @param array<class-string|Type> $options
1617
*/
1718
public function __construct(
18-
public Type|string $type,
19+
public Type|string|UnionType $type,
1920
array $options = [],
2021
public string $title = '',
2122
public string $description = '',
@@ -44,8 +45,25 @@ public function jsonSerialize(): array
4445
$property['default'] = $this->default;
4546
}
4647

48+
// Handle UnionType instance
49+
if ($this->type instanceof UnionType) {
50+
$unionOptions = [];
51+
foreach ($this->type->getTypes() as $unionType) {
52+
$typeName = $unionType->getName();
53+
if (\is_string($typeName) && !$unionType->isBuiltin()) {
54+
// Class reference
55+
$unionOptions[] = ['$ref' => (new Reference($typeName))->jsonSerialize()];
56+
} else {
57+
// Primitive type
58+
$unionOptions[] = ['type' => $typeName instanceof Type ? $typeName->value : $typeName];
59+
}
60+
}
61+
$property['oneOf'] = $unionOptions;
62+
return $property;
63+
}
64+
4765
if ($this->type === Type::Union) {
48-
$property['anyOf'] = $this->options->jsonSerialize();
66+
$property['oneOf'] = $this->options->jsonSerialize();
4967
return $property;
5068
}
5169

@@ -67,7 +85,7 @@ public function jsonSerialize(): array
6785

6886
$property['items']['type'] = $this->options[0]->value->value;
6987
} else {
70-
$property['items']['anyOf'] = $this->options->jsonSerialize();
88+
$property['items']['oneOf'] = $this->options->jsonSerialize();
7189
}
7290
}
7391

@@ -77,6 +95,17 @@ public function jsonSerialize(): array
7795
public function getDependencies(): array
7896
{
7997
$dependencies = [];
98+
99+
// Extract dependencies from union types
100+
if ($this->type instanceof UnionType) {
101+
foreach ($this->type->getTypes() as $unionType) {
102+
$typeName = $unionType->getName();
103+
if (!$unionType->isBuiltin() && \is_string($typeName)) {
104+
$dependencies[] = $typeName;
105+
}
106+
}
107+
}
108+
80109
foreach ($this->options->getOptions() as $option) {
81110
if (\is_string($option->value)) {
82111
$dependencies[] = $option->value;

src/Schema/PropertyOptions.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ final class PropertyOptions implements \Countable, \ArrayAccess, \JsonSerializab
2121
public function __construct(array $options = [])
2222
{
2323
foreach ($options as $option) {
24-
$this->options[] = new PropertyOption($option);
24+
$this->options[] = $option instanceof PropertyOption ? $option : new PropertyOption($option);
2525
}
2626
}
2727

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Tests\Unit\Fixture;
6+
7+
use Spiral\JsonSchemaGenerator\Attribute\Field;
8+
use Spiral\JsonSchemaGenerator\Attribute\Definition;
9+
10+
/**
11+
* Example of complex nested union types.
12+
*/
13+
#[Definition(title: 'Complex Union Types', description: 'Class with complex union type combinations')]
14+
class ComplexUnionTypes
15+
{
16+
/**
17+
* @var array<string|int>
18+
*/
19+
#[Field(title: 'Array of Union Types', description: 'An array that can contain strings or integers')]
20+
public array $arrayOfUnionTypes = [];
21+
22+
/**
23+
* @var array<Movie|Actor>
24+
*/
25+
#[Field(title: 'Array of Objects', description: 'An array that can contain different object types')]
26+
public array $arrayOfObjects = [];
27+
28+
public function __construct(
29+
#[Field(title: 'Nullable Union', description: 'A nullable union of primitive types')]
30+
public readonly string|int|null $nullableUnion = null,
31+
#[Field(title: 'Complex Property', description: 'Union of primitive, array, and object types')]
32+
public readonly string|array|Movie|null $complexProperty = null,
33+
#[Field(title: 'Default Value', description: 'Union type with default value')]
34+
public readonly string|int $defaultValue = 42,
35+
) {}
36+
}

0 commit comments

Comments
 (0)