Skip to content

Commit aea1bb6

Browse files
committed
add closure converter and union schema
1 parent 344a4df commit aea1bb6

16 files changed

Lines changed: 1213 additions & 385 deletions

README.md

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,35 @@ $schema = SchemaFactory::object('user')
4040
SchemaFactory::object('settings')
4141
->additionalProperties(false)
4242
->properties([
43-
SchemaFactory::string('theme')->enum(['light', 'dark']),
44-
SchemaFactory::boolean('notifications')
43+
SchemaFactory::string('theme')
44+
->enum(['light', 'dark']),
4545
]),
4646
);
4747
```
4848

49-
Or you can use the objects directly
49+
You can also use the objects directly instead of the factory methods.
5050
```php
51-
$schema = new ObjectSchema('user')
51+
$schema = (new ObjectSchema('user'))
5252
->description('User schema')
5353
->properties(
54-
new StringSchema('name')
54+
(new StringSchema('name'))
5555
->minLength(2)
5656
->maxLength(100)
5757
->required(),
58-
new StringSchema('email')
58+
(new StringSchema('email'))
5959
->format(SchemaFormat::Email)
6060
->required(),
61+
(new IntegerSchema('age'))
62+
->minimum(18)
63+
->maximum(150),
64+
(new BooleanSchema('active'))
65+
->default(true),
66+
(new ObjectSchema('settings'))
67+
->additionalProperties(false)
68+
->properties(
69+
(new StringSchema('theme'))
70+
->enum(['light', 'dark']),
71+
),
6172
);
6273
```
6374

@@ -68,23 +79,24 @@ $schema->toArray();
6879
// Convert to JSON string
6980
$schema->toJson();
7081

82+
$data = [
83+
'name' => 'John Doe',
84+
'email' => 'john@example.com',
85+
'age' => 16,
86+
'active' => true,
87+
'settings' => [
88+
'theme' => 'dark',
89+
],
90+
];
91+
7192
// Validate data against the schema
7293
try {
73-
$schema->validate([
74-
'name' => 'John Doe',
75-
'email' => 'john@example.com',
76-
'age' => 16,
77-
'active' => true,
78-
'settings' => [
79-
'theme' => 'dark',
80-
'notifications' => true,
81-
],
82-
]);
94+
$schema->validate($data);
8395
} catch (SchemaException $e) {
8496
echo $e->getMessage(); // "The data must match the 'email' format"
8597
}
8698

87-
// Validate data against the schema
99+
// Or just get a boolean
88100
$schema->isValid($data);
89101
```
90102

@@ -374,6 +386,42 @@ $schema->isValid([
374386

375387
---
376388

389+
### Union Schema
390+
391+
```php
392+
use Cortex\JsonSchema\SchemaFactory;
393+
use Cortex\JsonSchema\Enums\SchemaType;
394+
395+
$schema = SchemaFactory::union([SchemaType::String, SchemaType::Integer], 'id')
396+
->description('ID can be either a string or an integer')
397+
->enum(['abc123', 'def456', 1, 2, 3])
398+
->nullable();
399+
```
400+
401+
```php
402+
$schema->isValid('abc123'); // true
403+
$schema->isValid(1); // true
404+
$schema->isValid(null); // true (because it's nullable)
405+
$schema->isValid(true); // false (not a string or integer)
406+
$schema->isValid('invalid'); // false (not in enum)
407+
```
408+
409+
<details>
410+
<summary>View JSON Schema</summary>
411+
412+
```json
413+
{
414+
"$schema": "http://json-schema.org/draft-07/schema#",
415+
"type": ["string", "integer", "null"],
416+
"title": "id",
417+
"description": "ID can be either a string or an integer",
418+
"enum": ["abc123", "def456", 1, 2, 3]
419+
}
420+
```
421+
</details>
422+
423+
---
424+
377425
## Validation
378426

379427
The library throws a `SchemaException` when validation fails:

src/Contracts/Schema.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@ public function getType(): SchemaType|array;
4040
*/
4141
public function isRequired(): bool;
4242

43+
/**
44+
* Add null type to schema.
45+
*/
46+
public function nullable(): static;
47+
48+
/**
49+
* Set the default value
50+
*/
51+
public function default(mixed $value): static;
52+
53+
/**
54+
* Set the allowed enum values.
55+
*
56+
* @param non-empty-array<int|string|bool|float|null> $values
57+
*/
58+
public function enum(array $values): static;
59+
60+
/**
61+
* Set the schema as required.
62+
*/
63+
public function required(): static;
64+
4365
/**
4466
* Convert to array.
4567
*

src/Converters/FromClosure.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\JsonSchema\Converters;
6+
7+
use Closure;
8+
use ReflectionEnum;
9+
use ReflectionFunction;
10+
use ReflectionNamedType;
11+
use ReflectionParameter;
12+
use ReflectionUnionType;
13+
use ReflectionIntersectionType;
14+
use Cortex\JsonSchema\Contracts\Schema;
15+
use Cortex\JsonSchema\Enums\SchemaType;
16+
use Cortex\JsonSchema\Types\UnionSchema;
17+
use Cortex\JsonSchema\Types\ObjectSchema;
18+
use Cortex\JsonSchema\Exceptions\SchemaException;
19+
20+
class FromClosure
21+
{
22+
public static function convert(Closure $closure): ObjectSchema
23+
{
24+
$reflection = new ReflectionFunction($closure);
25+
$schema = new ObjectSchema();
26+
27+
// TODO: handle descriptions
28+
// $doc = $reflection->getDocComment();
29+
30+
foreach ($reflection->getParameters() as $parameter) {
31+
$propertySchema = self::getPropertySchema($parameter);
32+
33+
// No type hint, skip
34+
if ($propertySchema === null) {
35+
continue;
36+
}
37+
38+
$schema->properties($propertySchema);
39+
}
40+
41+
return $schema;
42+
}
43+
44+
/**
45+
* Create a schema from a given type.
46+
*/
47+
protected static function getPropertySchema(ReflectionParameter $parameter): ?Schema
48+
{
49+
$type = $parameter->getType();
50+
51+
if ($type === null) {
52+
return null;
53+
}
54+
55+
$matchedTypes = match (true) {
56+
$type instanceof ReflectionUnionType, $type instanceof ReflectionIntersectionType => array_map(
57+
fn(ReflectionNamedType $t): SchemaType => self::resolveSchemaType($t),
58+
$type->getTypes(),
59+
),
60+
$type instanceof ReflectionNamedType => [self::resolveSchemaType($type)],
61+
default => throw new SchemaException('Unknown type: ' . $type),
62+
};
63+
64+
// TODO: handle mixed type
65+
66+
$schema = count($matchedTypes) === 1
67+
? $matchedTypes[0]->instance()
68+
: new UnionSchema($matchedTypes);
69+
70+
$schema->title($parameter->getName());
71+
72+
if ($type->allowsNull()) {
73+
$schema->nullable();
74+
}
75+
76+
if ($parameter->isDefaultValueAvailable() && ! $parameter->isDefaultValueConstant()) {
77+
$schema->default($parameter->getDefaultValue());
78+
}
79+
80+
if (! $parameter->isOptional()) {
81+
$schema->required();
82+
}
83+
84+
// If it's an enum, add the possible values
85+
if ($type instanceof ReflectionNamedType) {
86+
$typeName = $type->getName();
87+
88+
if (enum_exists($typeName)) {
89+
$reflection = new ReflectionEnum($typeName);
90+
91+
if ($reflection->isBacked()) {
92+
$cases = $typeName::cases();
93+
$schema->enum(array_map(fn($case): int|string => $case->value, $cases));
94+
}
95+
}
96+
}
97+
98+
return $schema;
99+
}
100+
101+
/**
102+
* Resolve the schema type from the given reflection type.
103+
*/
104+
protected static function resolveSchemaType(ReflectionNamedType $type): SchemaType
105+
{
106+
$typeName = $type->getName();
107+
108+
if (enum_exists($typeName)) {
109+
$reflection = new ReflectionEnum($typeName);
110+
$typeName = $reflection->getBackingType()?->getName();
111+
112+
if ($typeName === null) {
113+
throw new SchemaException('Enum type has no backing type: ' . $typeName);
114+
}
115+
}
116+
117+
return SchemaType::fromScalar($typeName);
118+
}
119+
}

src/Enums/SchemaType.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Cortex\JsonSchema\Types\StringSchema;
1313
use Cortex\JsonSchema\Types\BooleanSchema;
1414
use Cortex\JsonSchema\Types\IntegerSchema;
15+
use Cortex\JsonSchema\Exceptions\SchemaException;
1516

1617
enum SchemaType: string
1718
{
@@ -38,4 +39,21 @@ public function instance(?string $title = null): Schema
3839
self::Null => new NullSchema($title),
3940
};
4041
}
42+
43+
/**
44+
* Create a new schema instance from a given scalar type.
45+
*/
46+
public static function fromScalar(string $type): self
47+
{
48+
return match ($type) {
49+
'int' => self::Integer,
50+
'float' => self::Number,
51+
'string' => self::String,
52+
'array' => self::Array,
53+
'bool' => self::Boolean,
54+
'object' => self::Object,
55+
'null' => self::Null,
56+
default => throw new SchemaException('Unknown type: ' . $type),
57+
};
58+
}
4159
}

src/SchemaFactory.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Cortex\JsonSchema\Types\NullSchema;
88
use Cortex\JsonSchema\Types\ArraySchema;
9+
use Cortex\JsonSchema\Types\UnionSchema;
910
use Cortex\JsonSchema\Types\NumberSchema;
1011
use Cortex\JsonSchema\Types\ObjectSchema;
1112
use Cortex\JsonSchema\Types\StringSchema;
@@ -48,4 +49,12 @@ public static function null(?string $title = null): NullSchema
4849
{
4950
return new NullSchema($title);
5051
}
52+
53+
/**
54+
* @param array<int, \Cortex\JsonSchema\Enums\SchemaType> $types
55+
*/
56+
public static function union(array $types, ?string $title = null): UnionSchema
57+
{
58+
return new UnionSchema($types, $title);
59+
}
5160
}

0 commit comments

Comments
 (0)