Skip to content

Commit 92529f0

Browse files
Add anyOf support to JSON schema (#60509)
* Add anyOf support to JSON schema * Fix JSON schema test imports * formatting --------- Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent 2f6cb3b commit 92529f0

9 files changed

Lines changed: 321 additions & 3 deletions

File tree

src/Illuminate/Contracts/JsonSchema/JsonSchema.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,12 @@ public function boolean();
5656
* @return \Illuminate\JsonSchema\Types\UnionType
5757
*/
5858
public function union(array $types);
59+
60+
/**
61+
* Create a new anyOf schema instance.
62+
*
63+
* @param (Closure(JsonSchema): array<int, \Illuminate\JsonSchema\Types\Type>)|array<int, \Illuminate\JsonSchema\Types\Type> $schemas
64+
* @return \Illuminate\JsonSchema\Types\AnyOfType
65+
*/
66+
public function anyOf(Closure|array $schemas);
5967
}

src/Illuminate/JsonSchema/Deserializer.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ protected function build(array $schema, array $refs = []): Types\Type
4747
{
4848
[$schema, $refs] = $this->resolveRef($schema, $refs);
4949

50+
if (($type = $this->buildAnyOfComposition($schema, $refs)) !== null) {
51+
$this->applyCommon($type, $schema);
52+
53+
return $type;
54+
}
55+
5056
[$schema, $nullableFromUnion, $refs] = $this->normalizeUnions($schema, $refs);
5157

5258
[$name, $nullableFromType] = $this->resolveType($schema);
@@ -76,6 +82,53 @@ protected function build(array $schema, array $refs = []): Types\Type
7682
return $type;
7783
}
7884

85+
/**
86+
* Build an anyOf composition unless it is the existing nullable single-schema form.
87+
*
88+
* @param array<string, mixed> $schema
89+
* @param array<int, string> $refs
90+
*
91+
* @throws \InvalidArgumentException
92+
*/
93+
protected function buildAnyOfComposition(array $schema, array $refs = []): ?Types\AnyOfType
94+
{
95+
if (! isset($schema['anyOf']) || ! is_array($schema['anyOf'])) {
96+
return null;
97+
}
98+
99+
$nullable = false;
100+
$branches = [];
101+
102+
foreach ($schema['anyOf'] as $branch) {
103+
if (! is_array($branch)) {
104+
throw new InvalidArgumentException('Unable to represent the schema for an anyOf branch; boolean schemas are not supported.');
105+
}
106+
107+
[$branch, $branchRefs] = $this->resolveRef($branch, $refs);
108+
109+
if ($this->isNullBranch($branch)) {
110+
$nullable = true;
111+
} else {
112+
$branches[] = [$branch, $branchRefs];
113+
}
114+
}
115+
116+
if ($nullable && count($branches) === 1) {
117+
return null;
118+
}
119+
120+
$type = new Types\AnyOfType(array_map(
121+
fn (array $branch) => $this->build($branch[0], $branch[1]),
122+
$branches,
123+
));
124+
125+
if ($nullable) {
126+
$type->nullable();
127+
}
128+
129+
return $type;
130+
}
131+
79132
/**
80133
* Build an object type from the given schema fragment.
81134
*

src/Illuminate/JsonSchema/JsonSchema.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
/**
99
* @method static Types\ObjectType object(Closure|array<string, Types\Type> $properties = [])
10+
* @method static Types\AnyOfType anyOf(Closure|array<int, Types\Type> $schemas)
1011
* @method static Types\IntegerType integer()
1112
* @method static Types\NumberType number()
1213
* @method static Types\StringType string()

src/Illuminate/JsonSchema/JsonSchemaTypeFactory.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,18 @@ public function union(array $types): Types\UnionType
7070
{
7171
return new Types\UnionType($types);
7272
}
73+
74+
/**
75+
* Create a new anyOf schema instance.
76+
*
77+
* @param (Closure(JsonSchemaTypeFactory): array<int, Types\Type>)|array<int, Types\Type> $schemas
78+
*/
79+
public function anyOf(Closure|array $schemas): Types\AnyOfType
80+
{
81+
if ($schemas instanceof Closure) {
82+
$schemas = $schemas($this);
83+
}
84+
85+
return new Types\AnyOfType($schemas);
86+
}
7387
}

src/Illuminate/JsonSchema/Serializer.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ public static function serialize(Types\Type $type): array
2525
/** @var array<string, mixed> $attributes */
2626
$attributes = (fn () => get_object_vars($type))->call($type);
2727

28+
if ($type instanceof Types\AnyOfType) {
29+
$attributes['anyOf'] = array_map(
30+
static fn (Types\Type $schema) => static::serialize($schema),
31+
$attributes['schemas'],
32+
);
33+
34+
unset($attributes['schemas']);
35+
36+
if (static::isNullable($type)) {
37+
$attributes['anyOf'][] = ['type' => 'null'];
38+
}
39+
40+
return array_filter($attributes, static function (mixed $value, string $key) {
41+
if (in_array($key, static::$ignore, true)) {
42+
return false;
43+
}
44+
45+
return $value !== null;
46+
}, ARRAY_FILTER_USE_BOTH);
47+
}
48+
2849
$attributes['type'] = match (get_class($type)) {
2950
Types\ArrayType::class => 'array',
3051
Types\BooleanType::class => 'boolean',
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Illuminate\JsonSchema\Types;
4+
5+
class AnyOfType extends Type
6+
{
7+
/**
8+
* Create a new anyOf type instance.
9+
*
10+
* @param array<int, Type> $schemas
11+
*/
12+
public function __construct(protected array $schemas)
13+
{
14+
$this->schemas = array_values($schemas);
15+
}
16+
17+
/**
18+
* Get the anyOf schemas.
19+
*
20+
* @return array<int, Type>
21+
*/
22+
public function schemas(): array
23+
{
24+
return $this->schemas;
25+
}
26+
}

tests/JsonSchema/AnyOfTypeTest.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\JsonSchema;
4+
5+
use Illuminate\JsonSchema\JsonSchema;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class AnyOfTypeTest extends TestCase
9+
{
10+
public function test_it_may_describe_any_of_multiple_schemas(): void
11+
{
12+
$type = JsonSchema::anyOf([
13+
JsonSchema::string(),
14+
JsonSchema::integer(),
15+
])->title('Identifier');
16+
17+
$this->assertEquals([
18+
'title' => 'Identifier',
19+
'anyOf' => [
20+
['type' => 'string'],
21+
['type' => 'integer'],
22+
],
23+
], $type->toArray());
24+
}
25+
26+
public function test_it_may_be_initialized_with_a_closure(): void
27+
{
28+
$type = JsonSchema::anyOf(fn (JsonSchema $schema): array => [
29+
$schema->string(),
30+
$schema->integer(),
31+
]);
32+
33+
$this->assertEquals([
34+
'anyOf' => [
35+
['type' => 'string'],
36+
['type' => 'integer'],
37+
],
38+
], $type->toArray());
39+
}
40+
41+
public function test_it_may_be_nullable(): void
42+
{
43+
$type = JsonSchema::anyOf([
44+
JsonSchema::string(),
45+
JsonSchema::integer(),
46+
])->nullable();
47+
48+
$this->assertEquals([
49+
'anyOf' => [
50+
['type' => 'string'],
51+
['type' => 'integer'],
52+
['type' => 'null'],
53+
],
54+
], $type->toArray());
55+
}
56+
57+
public function test_it_may_describe_object_unions(): void
58+
{
59+
$type = JsonSchema::anyOf([
60+
JsonSchema::object([
61+
'type' => JsonSchema::string()->enum(['article'])->required(),
62+
'title' => JsonSchema::string()->required(),
63+
'content' => JsonSchema::string()->required(),
64+
]),
65+
JsonSchema::object([
66+
'type' => JsonSchema::string()->enum(['image'])->required(),
67+
'url' => JsonSchema::string()->required(),
68+
'caption' => JsonSchema::string(),
69+
]),
70+
]);
71+
72+
$this->assertEquals([
73+
'anyOf' => [
74+
[
75+
'type' => 'object',
76+
'properties' => [
77+
'type' => ['type' => 'string', 'enum' => ['article']],
78+
'title' => ['type' => 'string'],
79+
'content' => ['type' => 'string'],
80+
],
81+
'required' => ['type', 'title', 'content'],
82+
],
83+
[
84+
'type' => 'object',
85+
'properties' => [
86+
'type' => ['type' => 'string', 'enum' => ['image']],
87+
'url' => ['type' => 'string'],
88+
'caption' => ['type' => 'string'],
89+
],
90+
'required' => ['type', 'url'],
91+
],
92+
],
93+
], $type->toArray());
94+
}
95+
}

tests/JsonSchema/DeserializerTest.php

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\JsonSchema\JsonSchema;
66
use Illuminate\JsonSchema\Serializer;
7+
use Illuminate\JsonSchema\Types\AnyOfType;
78
use Illuminate\JsonSchema\Types\ArrayType;
89
use Illuminate\JsonSchema\Types\BooleanType;
910
use Illuminate\JsonSchema\Types\IntegerType;
@@ -544,6 +545,76 @@ public function test_it_deserializes_a_union_nested_in_array_items(): void
544545
], $type->toArray());
545546
}
546547

548+
public function test_it_deserializes_an_any_of_composition(): void
549+
{
550+
$type = JsonSchema::fromArray([
551+
'title' => 'Identifier',
552+
'anyOf' => [
553+
['type' => 'string'],
554+
['type' => 'integer'],
555+
],
556+
]);
557+
558+
$this->assertInstanceOf(AnyOfType::class, $type);
559+
$this->assertEquals([
560+
'title' => 'Identifier',
561+
'anyOf' => [
562+
['type' => 'string'],
563+
['type' => 'integer'],
564+
],
565+
], $type->toArray());
566+
}
567+
568+
public function test_it_deserializes_a_nullable_any_of_composition(): void
569+
{
570+
$type = JsonSchema::fromArray([
571+
'anyOf' => [
572+
['type' => 'string'],
573+
['type' => 'integer'],
574+
['type' => 'null'],
575+
],
576+
]);
577+
578+
$this->assertInstanceOf(AnyOfType::class, $type);
579+
$this->assertEquals([
580+
'anyOf' => [
581+
['type' => 'string'],
582+
['type' => 'integer'],
583+
['type' => 'null'],
584+
],
585+
], $type->toArray());
586+
}
587+
588+
public function test_it_deserializes_an_any_of_nested_in_an_object_property(): void
589+
{
590+
$type = JsonSchema::fromArray([
591+
'type' => 'object',
592+
'properties' => [
593+
'value' => [
594+
'anyOf' => [
595+
['type' => 'string'],
596+
['type' => 'integer'],
597+
],
598+
],
599+
],
600+
'required' => ['value'],
601+
]);
602+
603+
$this->assertInstanceOf(ObjectType::class, $type);
604+
$this->assertEquals([
605+
'type' => 'object',
606+
'properties' => [
607+
'value' => [
608+
'anyOf' => [
609+
['type' => 'string'],
610+
['type' => 'integer'],
611+
],
612+
],
613+
],
614+
'required' => ['value'],
615+
], $type->toArray());
616+
}
617+
547618
public function test_it_throws_for_an_unsupported_union_member(): void
548619
{
549620
$this->expectException(InvalidArgumentException::class);
@@ -638,13 +709,13 @@ public function test_it_throws_when_a_union_branch_conflicts_with_sibling_keys()
638709
]);
639710
}
640711

641-
public function test_it_throws_for_an_unsupported_union(): void
712+
public function test_it_throws_for_an_unsupported_one_of_union(): void
642713
{
643714
$this->expectException(InvalidArgumentException::class);
644-
$this->expectExceptionMessage('Only a nullable "anyOf" (a single schema plus a "null" branch) is supported.');
715+
$this->expectExceptionMessage('Only a nullable "oneOf" (a single schema plus a "null" branch) is supported.');
645716

646717
JsonSchema::fromArray([
647-
'anyOf' => [
718+
'oneOf' => [
648719
['type' => 'string'],
649720
['type' => 'integer'],
650721
],

0 commit comments

Comments
 (0)