From 63707cb1a76e0eab92dee55a954b9d6e725f2dc7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 22:54:16 +0000
Subject: [PATCH 1/9] Document JSON Schema usage
Add the first user-facing guide for building, serializing, and reconstructing JSON schemas.
Cover primitive and structured schemas, metadata, required and nullable properties, unions, any-of schemas, local references, and the supported reconstruction subset.
---
src/boost/docs/json-schema.md | 238 ++++++++++++++++++++++++++++++++++
1 file changed, 238 insertions(+)
create mode 100644 src/boost/docs/json-schema.md
diff --git a/src/boost/docs/json-schema.md b/src/boost/docs/json-schema.md
new file mode 100644
index 000000000..21e24c5f0
--- /dev/null
+++ b/src/boost/docs/json-schema.md
@@ -0,0 +1,238 @@
+# JSON Schema
+
+- [Introduction](#introduction)
+- [Building Schemas](#building-schemas)
+ - [Primitive Types](#primitive-types)
+ - [Object Schemas](#object-schemas)
+ - [Array Schemas](#array-schemas)
+ - [Metadata and Constraints](#metadata-and-constraints)
+ - [Required and Nullable Properties](#required-and-nullable-properties)
+ - [Union Types](#union-types)
+ - [Any-Of Schemas](#any-of-schemas)
+- [Serializing Schemas](#serializing-schemas)
+- [Reconstructing Schemas](#reconstructing-schemas)
+ - [Local References](#local-references)
+ - [Supported Schema Subset](#supported-schema-subset)
+
+
+## Introduction
+
+Hypervel's JSON Schema builder provides a fluent way to describe structured data. The generated schemas follow JSON Schema 2020-12 and may be passed to APIs, AI tools, validators, or any other service that accepts JSON Schema:
+
+```php
+use Hypervel\JsonSchema\JsonSchema;
+
+$schema = JsonSchema::object([
+ 'name' => JsonSchema::string()->required(),
+ 'age' => JsonSchema::integer()->min(0),
+]);
+```
+
+Each builder is a fresh, independent object, so schemas may be safely constructed for individual requests or operations.
+
+
+## Building Schemas
+
+
+### Primitive Types
+
+You may create string, integer, number, and boolean schemas using their corresponding methods:
+
+```php
+$name = JsonSchema::string();
+$age = JsonSchema::integer();
+$price = JsonSchema::number();
+$enabled = JsonSchema::boolean();
+```
+
+
+### Object Schemas
+
+The `object` method accepts an array of named property schemas:
+
+```php
+$schema = JsonSchema::object([
+ 'name' => JsonSchema::string()->required(),
+ 'email' => JsonSchema::string()->format('email')->required(),
+ 'profile' => JsonSchema::object([
+ 'bio' => JsonSchema::string()->max(500),
+ ]),
+]);
+```
+
+A closure may be used when you prefer to build properties from the provided factory:
+
+```php
+use Hypervel\JsonSchema\JsonSchemaTypeFactory;
+
+$schema = JsonSchema::object(fn (JsonSchemaTypeFactory $schema) => [
+ 'name' => $schema->string()->required(),
+ 'active' => $schema->boolean()->default(true),
+]);
+```
+
+By default, object schemas allow properties that are not explicitly declared. You may prevent additional properties using the `withoutAdditionalProperties` method:
+
+```php
+$schema = JsonSchema::object([
+ 'name' => JsonSchema::string(),
+])->withoutAdditionalProperties();
+```
+
+
+### Array Schemas
+
+The `array` method creates an array schema. You may describe its items and restrict its size using the `items`, `min`, and `max` methods:
+
+```php
+$schema = JsonSchema::array()
+ ->items(JsonSchema::string())
+ ->min(1)
+ ->max(10);
+```
+
+The `unique` method requires every item to be unique:
+
+```php
+$schema = JsonSchema::array()
+ ->items(JsonSchema::integer())
+ ->unique();
+```
+
+
+### Metadata and Constraints
+
+Every schema type supports `title`, `description`, `default`, and `enum`:
+
+```php
+$schema = JsonSchema::string()
+ ->title('Status')
+ ->description('The current publication status.')
+ ->default('draft')
+ ->enum(['draft', 'published']);
+```
+
+You may also provide a backed enum class. Its backed values will be used as the allowed values:
+
+```php
+$schema = JsonSchema::string()->enum(Status::class);
+```
+
+String schemas also provide `min`, `max`, `pattern`, and `format`. Integer and number schemas provide `min`, `max`, and `multipleOf`. Array schemas provide `min`, `max`, `items`, and `unique`.
+
+Defaults are annotations and are not validated against the schema. An explicit `null` default is preserved even when the schema itself is not nullable.
+
+
+### Required and Nullable Properties
+
+The `required` and `nullable` methods control different behavior. Calling `required` means an object property must be present. Calling `nullable` means its value may be `null`:
+
+```php
+$schema = JsonSchema::object([
+ 'name' => JsonSchema::string()->required(),
+ 'nickname' => JsonSchema::string()->nullable(),
+]);
+```
+
+In this example, `name` must be present. The optional `nickname` property may be omitted, or it may contain a string or `null` value. Passing `false` to either method removes that setting.
+
+
+### Union Types
+
+The `union` method accepts JSON Schema primitive type names and allows a value to match any of them:
+
+```php
+$schema = JsonSchema::union(['string', 'integer']);
+```
+
+Union schemas may also be nullable or carry shared metadata:
+
+```php
+$schema = JsonSchema::union(['string', 'number'])
+ ->title('Identifier')
+ ->nullable();
+```
+
+
+### Any-Of Schemas
+
+Use the `anyOf` method when each alternative needs its own constraints:
+
+```php
+$schema = JsonSchema::anyOf([
+ JsonSchema::string()->format('uuid'),
+ JsonSchema::integer()->min(1),
+]);
+```
+
+You may also provide a closure that receives the schema factory:
+
+```php
+$schema = JsonSchema::anyOf(fn ($schema) => [
+ $schema->string(),
+ $schema->integer(),
+]);
+```
+
+
+## Serializing Schemas
+
+The `toArray` method returns the schema as an array, while `toString` returns formatted JSON. Schema builders may also be cast directly to a string:
+
+```php
+$array = $schema->toArray();
+$json = $schema->toString();
+$json = (string) $schema;
+```
+
+String conversion throws a `JsonException` when a default, enum value, or other schema value cannot be encoded as JSON. Calling `toArray` or converting to JSON throws an `InvalidArgumentException` for an empty union or any-of builder unless `nullable` adds a valid `null` alternative.
+
+
+## Reconstructing Schemas
+
+The `fromArray` method reconstructs a builder from a supported JSON Schema array:
+
+```php
+$schema = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ 'required' => ['name'],
+]);
+```
+
+This is useful when a schema is stored as configuration or received from another trusted source and you need to extend it or serialize it through the builder.
+
+
+### Local References
+
+`fromArray` resolves local JSON Pointer references, including references into `$defs`:
+
+```php
+$schema = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'author' => ['$ref' => '#/$defs/user'],
+ ],
+ '$defs' => [
+ 'user' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ ],
+ ],
+]);
+```
+
+Remote references are not supported. Circular references, excessive reference depth, and excessive total expansion throw an `InvalidArgumentException`.
+
+
+### Supported Schema Subset
+
+The builder reconstructs the primitive, object, array, union, any-of, nullable, metadata, enum, default, and constraint keywords exposed by its fluent API. Null-only schemas are accepted using either the scalar or array form. A permissive `items: true` behaves like an omitted item constraint. Standard annotations and vendor extensions that are not modeled by the builder are ignored.
+
+Unsupported JSON Schema 2020-12 assertions are rejected instead of being silently removed. These include `const`, `not`, `allOf`, `if`, `dependentSchemas`, `dependentRequired`, `prefixItems`, `contains`, `patternProperties`, `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `exclusiveMinimum`, `exclusiveMaximum`, `minProperties`, `maxProperties`, and `$dynamicRef`.
+
+An `InvalidArgumentException` is also thrown for malformed recognized keywords, empty input compositions, schema-valued `additionalProperties`, tuple or false `items`, boolean property schemas, type-specific assertions on unions, and competing compositions. This prevents a reconstructed builder from silently accepting data that the original schema rejected.
From 1fb4d39aae9c5c489e6e6f8a5bbc95eca247bde7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:03:47 +0000
Subject: [PATCH 2/9] Bring JSON Schema serialization to current parity
Preserve explicit null defaults independently from unset defaults, make required, nullable, and unique flags reversible, and retain valid empty enum values.
Emit numeric property maps and list-shaped object defaults as JSON objects, validate final union and any-of output after nullability is applied, and preserve JSON encoding failures with JSON_THROW_ON_ERROR. Add focused regression coverage for every corrected type and wire shape.
---
src/json-schema/src/Serializer.php | 82 +++++++++--
src/json-schema/src/Types/ArrayType.php | 23 ++-
src/json-schema/src/Types/BooleanType.php | 6 +-
src/json-schema/src/Types/IntegerType.php | 6 +-
src/json-schema/src/Types/NumberType.php | 6 +-
src/json-schema/src/Types/ObjectType.php | 8 +-
src/json-schema/src/Types/StringType.php | 6 +-
src/json-schema/src/Types/Type.php | 41 ++++--
tests/JsonSchema/ArrayTypeTest.php | 53 ++++++-
tests/JsonSchema/BooleanTypeTest.php | 17 ++-
tests/JsonSchema/IntegerTypeTest.php | 21 ++-
tests/JsonSchema/NumberTypeTest.php | 27 ++--
tests/JsonSchema/ObjectTypeTest.php | 77 +++++++++-
tests/JsonSchema/SerializerTest.php | 2 +-
tests/JsonSchema/StringTypeTest.php | 19 ++-
tests/JsonSchema/TypeTest.php | 164 ++++++++++++++++++++--
16 files changed, 465 insertions(+), 93 deletions(-)
diff --git a/src/json-schema/src/Serializer.php b/src/json-schema/src/Serializer.php
index d75e35a12..cc33d252b 100644
--- a/src/json-schema/src/Serializer.php
+++ b/src/json-schema/src/Serializer.php
@@ -4,6 +4,7 @@
namespace Hypervel\JsonSchema;
+use InvalidArgumentException;
use RuntimeException;
class Serializer
@@ -13,13 +14,14 @@ class Serializer
*
* @var array
*/
- protected static array $ignore = ['required', 'nullable'];
+ protected static array $ignore = ['required', 'nullable', 'hasDefault'];
/**
* Serialize the given property to an array.
*
* @return array
*
+ * @throws InvalidArgumentException
* @throws RuntimeException
*/
public static function serialize(Types\Type $type): array
@@ -27,6 +29,25 @@ public static function serialize(Types\Type $type): array
/** @var array $attributes */
$attributes = (fn () => get_object_vars($type))->call($type);
+ if ($type instanceof Types\AnyOfType) {
+ $attributes['anyOf'] = array_map(
+ static fn (Types\Type $schema) => static::serialize($schema),
+ $attributes['schemas'],
+ );
+
+ unset($attributes['schemas']);
+
+ if (static::isNullable($type)) {
+ $attributes['anyOf'][] = ['type' => 'null'];
+ }
+
+ if ($attributes['anyOf'] === []) {
+ throw new InvalidArgumentException('A JSON Schema anyOf must contain at least one schema.');
+ }
+
+ return static::filterAttributes($attributes);
+ }
+
$attributes['type'] = match (get_class($type)) {
Types\ArrayType::class => 'array',
Types\BooleanType::class => 'boolean',
@@ -34,40 +55,54 @@ public static function serialize(Types\Type $type): array
Types\NumberType::class => 'number',
Types\ObjectType::class => 'object',
Types\StringType::class => 'string',
+ Types\UnionType::class => $attributes['types'],
default => throw new RuntimeException('Unsupported [' . get_class($type) . '] type.'),
};
+ unset($attributes['types']);
+
$nullable = static::isNullable($type);
if ($nullable) {
- $attributes['type'] = [$attributes['type'], 'null'];
+ $attributes['type'] = is_array($attributes['type'])
+ ? [...$attributes['type'], 'null']
+ : [$attributes['type'], 'null'];
}
- $attributes = array_filter($attributes, static function (mixed $value, string $key) {
- if (in_array($key, static::$ignore, true)) {
- return false;
- }
+ if ($attributes['type'] === []) {
+ throw new InvalidArgumentException('A JSON Schema union must contain at least one type.');
+ }
- return $value !== null;
- }, ARRAY_FILTER_USE_BOTH);
+ $attributes = static::filterAttributes($attributes);
if ($type instanceof Types\ObjectType) {
+ if (isset($attributes['default']) && is_array($attributes['default']) && array_is_list($attributes['default'])) {
+ $attributes['default'] = (object) $attributes['default'];
+ }
+
if (count($attributes['properties']) === 0) {
unset($attributes['properties']);
} else {
- $required = array_keys(array_filter(
- $attributes['properties'],
- static fn (Types\Type $property) => static::isRequired($property),
- ));
+ $required = array_map(
+ 'strval',
+ array_keys(array_filter(
+ $attributes['properties'],
+ static fn (Types\Type $property) => static::isRequired($property),
+ ))
+ );
- if (count($required) > 0) {
+ if ($required !== []) {
$attributes['required'] = $required;
}
- $attributes['properties'] = array_map(
+ $properties = array_map(
static fn (Types\Type $property) => static::serialize($property),
$attributes['properties'],
);
+
+ $attributes['properties'] = array_is_list($properties)
+ ? (object) $properties
+ : $properties;
}
}
@@ -80,6 +115,25 @@ public static function serialize(Types\Type $type): array
return $attributes;
}
+ /**
+ * Remove internal and unset attributes before publication.
+ *
+ * @param array $attributes
+ * @return array
+ */
+ protected static function filterAttributes(array $attributes): array
+ {
+ $hasDefault = $attributes['hasDefault'];
+
+ return array_filter($attributes, static function (mixed $value, string $key) use ($hasDefault): bool {
+ if (in_array($key, static::$ignore, true)) {
+ return false;
+ }
+
+ return $value !== null || ($key === 'default' && $hasDefault);
+ }, ARRAY_FILTER_USE_BOTH);
+ }
+
/**
* Determine if the given type is required.
*/
diff --git a/src/json-schema/src/Types/ArrayType.php b/src/json-schema/src/Types/ArrayType.php
index 647dea882..64c2fa9b4 100644
--- a/src/json-schema/src/Types/ArrayType.php
+++ b/src/json-schema/src/Types/ArrayType.php
@@ -21,6 +21,11 @@ class ArrayType extends Type
*/
protected ?Type $items = null;
+ /**
+ * Whether the array items must be unique.
+ */
+ protected ?bool $uniqueItems = null;
+
/**
* Set the minimum number of items (inclusive).
*/
@@ -52,14 +57,22 @@ public function items(Type $type): static
}
/**
- * Set the type's default value.
- *
- * @param array $value
+ * Indicate that the array items must be unique.
*/
- public function default(array $value): static
+ public function unique(bool $unique = true): static
{
- $this->default = $value;
+ $this->uniqueItems = $unique ?: null;
return $this;
}
+
+ /**
+ * Set the type's default value.
+ *
+ * @param null|array $value
+ */
+ public function default(?array $value): static
+ {
+ return $this->setDefault($value);
+ }
}
diff --git a/src/json-schema/src/Types/BooleanType.php b/src/json-schema/src/Types/BooleanType.php
index 1a6c7690c..ba907d13d 100644
--- a/src/json-schema/src/Types/BooleanType.php
+++ b/src/json-schema/src/Types/BooleanType.php
@@ -9,10 +9,8 @@ class BooleanType extends Type
/**
* Set the type's default value.
*/
- public function default(bool $value): static
+ public function default(?bool $value): static
{
- $this->default = $value;
-
- return $this;
+ return $this->setDefault($value);
}
}
diff --git a/src/json-schema/src/Types/IntegerType.php b/src/json-schema/src/Types/IntegerType.php
index ec058504b..679696be0 100644
--- a/src/json-schema/src/Types/IntegerType.php
+++ b/src/json-schema/src/Types/IntegerType.php
@@ -54,10 +54,8 @@ public function multipleOf(int $value): static
/**
* Set the type's default value.
*/
- public function default(int $value): static
+ public function default(?int $value): static
{
- $this->default = $value;
-
- return $this;
+ return $this->setDefault($value);
}
}
diff --git a/src/json-schema/src/Types/NumberType.php b/src/json-schema/src/Types/NumberType.php
index 2a83ebad8..4ccbdd716 100644
--- a/src/json-schema/src/Types/NumberType.php
+++ b/src/json-schema/src/Types/NumberType.php
@@ -54,10 +54,8 @@ public function multipleOf(int|float $value): static
/**
* Set the type's default value.
*/
- public function default(int|float $value): static
+ public function default(int|float|null $value): static
{
- $this->default = $value;
-
- return $this;
+ return $this->setDefault($value);
}
}
diff --git a/src/json-schema/src/Types/ObjectType.php b/src/json-schema/src/Types/ObjectType.php
index 0885b03c7..1227c1489 100644
--- a/src/json-schema/src/Types/ObjectType.php
+++ b/src/json-schema/src/Types/ObjectType.php
@@ -33,12 +33,10 @@ public function withoutAdditionalProperties(): static
/**
* Set the type's default value.
*
- * @param array $value
+ * @param null|array $value
*/
- public function default(array $value): static
+ public function default(?array $value): static
{
- $this->default = $value;
-
- return $this;
+ return $this->setDefault($value);
}
}
diff --git a/src/json-schema/src/Types/StringType.php b/src/json-schema/src/Types/StringType.php
index d0c65c6a9..0e5246dc9 100644
--- a/src/json-schema/src/Types/StringType.php
+++ b/src/json-schema/src/Types/StringType.php
@@ -71,10 +71,8 @@ public function format(string $value): static
/**
* Set the type's default value.
*/
- public function default(string $value): static
+ public function default(?string $value): static
{
- $this->default = $value;
-
- return $this;
+ return $this->setDefault($value);
}
}
diff --git a/src/json-schema/src/Types/Type.php b/src/json-schema/src/Types/Type.php
index 7a47a594b..80a56576c 100644
--- a/src/json-schema/src/Types/Type.php
+++ b/src/json-schema/src/Types/Type.php
@@ -8,6 +8,8 @@
use Hypervel\JsonSchema\JsonSchema;
use Hypervel\JsonSchema\Serializer;
use InvalidArgumentException;
+use JsonException;
+use RuntimeException;
abstract class Type extends JsonSchema
{
@@ -31,6 +33,11 @@ abstract class Type extends JsonSchema
*/
protected mixed $default = null;
+ /**
+ * Whether a default value was provided.
+ */
+ protected bool $hasDefault = false;
+
/**
* The set of allowed values for the type.
*
@@ -48,21 +55,17 @@ abstract class Type extends JsonSchema
*/
public function required(bool $required = true): static
{
- if ($required) {
- $this->required = true;
- }
+ $this->required = $required ?: null;
return $this;
}
/**
- * Indicate that the type is optional.
+ * Indicate that the type may be null.
*/
public function nullable(bool $nullable = true): static
{
- if ($nullable) {
- $this->nullable = true;
- }
+ $this->nullable = $nullable ?: null;
return $this;
}
@@ -87,6 +90,17 @@ public function description(string $value): static
return $this;
}
+ /**
+ * Set the type's default value.
+ */
+ protected function setDefault(mixed $value): static
+ {
+ $this->default = $value;
+ $this->hasDefault = true;
+
+ return $this;
+ }
+
/**
* Restrict the value to one of the provided enumerated values.
*
@@ -114,6 +128,9 @@ public function enum(array|string $values): static
* Convert the type to an array.
*
* @return array
+ *
+ * @throws InvalidArgumentException
+ * @throws RuntimeException
*/
public function toArray(): array
{
@@ -122,14 +139,22 @@ public function toArray(): array
/**
* Convert the type to its string representation.
+ *
+ * @throws InvalidArgumentException
+ * @throws JsonException
+ * @throws RuntimeException
*/
public function toString(): string
{
- return json_encode($this->toArray(), JSON_PRETTY_PRINT) ?: '';
+ return json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
}
/**
* Convert the type to its string representation.
+ *
+ * @throws InvalidArgumentException
+ * @throws JsonException
+ * @throws RuntimeException
*/
public function __toString(): string
{
diff --git a/tests/JsonSchema/ArrayTypeTest.php b/tests/JsonSchema/ArrayTypeTest.php
index 5f85939a6..74075f1a4 100644
--- a/tests/JsonSchema/ArrayTypeTest.php
+++ b/tests/JsonSchema/ArrayTypeTest.php
@@ -9,7 +9,7 @@
class ArrayTypeTest extends TestCase
{
- public function testItMaySetMinItems()
+ public function testItMaySetMinItems(): void
{
$type = JsonSchema::array()->title('Tags')->min(1);
@@ -20,7 +20,7 @@ public function testItMaySetMinItems()
], $type->toArray());
}
- public function testItMaySetMaxItems()
+ public function testItMaySetMaxItems(): void
{
$type = JsonSchema::array()->description('A list of tags')->max(10);
@@ -31,7 +31,7 @@ public function testItMaySetMaxItems()
], $type->toArray());
}
- public function testItMaySetItemsType()
+ public function testItMaySetItemsType(): void
{
$type = JsonSchema::array()->items(
JsonSchema::string()->max(20)
@@ -46,7 +46,7 @@ public function testItMaySetItemsType()
], $type->toArray());
}
- public function testItMaySetDefaultValue()
+ public function testItMaySetDefaultValue(): void
{
$type = JsonSchema::array()->default(['a', 'b']);
@@ -56,7 +56,50 @@ public function testItMaySetDefaultValue()
], $type->toArray());
}
- public function testItMaySetEnum()
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::array()->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'array',
+ ], JsonSchema::array()->default(null)->toArray());
+ }
+
+ public function testItMaySetUniqueItems(): void
+ {
+ $type = JsonSchema::array()->items(JsonSchema::string())->unique();
+
+ $this->assertEquals([
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ ],
+ 'uniqueItems' => true,
+ ], $type->toArray());
+ }
+
+ public function testItMayUnsetUniqueItems(): void
+ {
+ $type = JsonSchema::array()->unique()->unique(false);
+
+ $this->assertEquals([
+ 'type' => 'array',
+ ], $type->toArray());
+ }
+
+ public function testItMayCombineUniqueItemsWithMinAndMax(): void
+ {
+ $type = JsonSchema::array()->min(1)->max(5)->unique();
+
+ $this->assertEquals([
+ 'type' => 'array',
+ 'minItems' => 1,
+ 'maxItems' => 5,
+ 'uniqueItems' => true,
+ ], $type->toArray());
+ }
+
+ public function testItMaySetEnum(): void
{
$type = JsonSchema::array()->enum([
['a'],
diff --git a/tests/JsonSchema/BooleanTypeTest.php b/tests/JsonSchema/BooleanTypeTest.php
index 86b002a1c..ec8699879 100644
--- a/tests/JsonSchema/BooleanTypeTest.php
+++ b/tests/JsonSchema/BooleanTypeTest.php
@@ -9,7 +9,7 @@
class BooleanTypeTest extends TestCase
{
- public function testSerializesAsBooleanWithMetadata()
+ public function testSerializesAsBooleanWithMetadata(): void
{
$type = JsonSchema::boolean()->title('Enabled')->description('Feature flag');
@@ -20,7 +20,7 @@ public function testSerializesAsBooleanWithMetadata()
], $type->toArray());
}
- public function testMaySetDefaultTrueViaDefault()
+ public function testMaySetDefaultTrueViaDefault(): void
{
$type = JsonSchema::boolean()->default(true);
@@ -30,7 +30,7 @@ public function testMaySetDefaultTrueViaDefault()
], $type->toArray());
}
- public function testMaySetDefaultFalseViaDefault()
+ public function testMaySetDefaultFalseViaDefault(): void
{
$type = JsonSchema::boolean()->default(false);
@@ -40,7 +40,16 @@ public function testMaySetDefaultFalseViaDefault()
], $type->toArray());
}
- public function testMaySetEnum()
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::boolean()->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'boolean',
+ ], JsonSchema::boolean()->default(null)->toArray());
+ }
+
+ public function testMaySetEnum(): void
{
$type = JsonSchema::boolean()->enum([true, false]);
diff --git a/tests/JsonSchema/IntegerTypeTest.php b/tests/JsonSchema/IntegerTypeTest.php
index 02cbe0487..9a3365987 100644
--- a/tests/JsonSchema/IntegerTypeTest.php
+++ b/tests/JsonSchema/IntegerTypeTest.php
@@ -9,7 +9,7 @@
class IntegerTypeTest extends TestCase
{
- public function testItMaySetMinValue()
+ public function testItMaySetMinValue(): void
{
$type = JsonSchema::integer()->title('Age')->min(5);
@@ -20,7 +20,7 @@ public function testItMaySetMinValue()
], $type->toArray());
}
- public function testItMaySetMaxValue()
+ public function testItMaySetMaxValue(): void
{
$type = JsonSchema::integer()->description('Max age')->max(10);
@@ -31,7 +31,7 @@ public function testItMaySetMaxValue()
], $type->toArray());
}
- public function testItMaySetDefaultValue()
+ public function testItMaySetDefaultValue(): void
{
$type = JsonSchema::integer()->default(18);
@@ -41,7 +41,16 @@ public function testItMaySetDefaultValue()
], $type->toArray());
}
- public function testItMaySetMultipleOf()
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::integer()->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'integer',
+ ], JsonSchema::integer()->default(null)->toArray());
+ }
+
+ public function testItMaySetMultipleOf(): void
{
$type = JsonSchema::integer()->multipleOf(5);
@@ -51,7 +60,7 @@ public function testItMaySetMultipleOf()
], $type->toArray());
}
- public function testItMayCombineMultipleOfWithMinAndMax()
+ public function testItMayCombineMultipleOfWithMinAndMax(): void
{
$type = JsonSchema::integer()->min(0)->max(100)->multipleOf(10);
@@ -63,7 +72,7 @@ public function testItMayCombineMultipleOfWithMinAndMax()
], $type->toArray());
}
- public function testItMaySetEnum()
+ public function testItMaySetEnum(): void
{
$type = JsonSchema::integer()->enum([1, 2, 3]);
diff --git a/tests/JsonSchema/NumberTypeTest.php b/tests/JsonSchema/NumberTypeTest.php
index 4ffd84d52..f1e5f67a5 100644
--- a/tests/JsonSchema/NumberTypeTest.php
+++ b/tests/JsonSchema/NumberTypeTest.php
@@ -9,7 +9,7 @@
class NumberTypeTest extends TestCase
{
- public function testItMaySetMinValueAsFloat()
+ public function testItMaySetMinValueAsFloat(): void
{
$type = JsonSchema::number()->title('Price')->min(5.5);
@@ -20,7 +20,7 @@ public function testItMaySetMinValueAsFloat()
], $type->toArray());
}
- public function testItMaySetMinValueAsInt()
+ public function testItMaySetMinValueAsInt(): void
{
$type = JsonSchema::number()->title('Price')->min(5);
@@ -31,7 +31,7 @@ public function testItMaySetMinValueAsInt()
], $type->toArray());
}
- public function testItMaySetMaxValueAsFloat()
+ public function testItMaySetMaxValueAsFloat(): void
{
$type = JsonSchema::number()->description('Max price')->max(10.75);
@@ -42,7 +42,7 @@ public function testItMaySetMaxValueAsFloat()
], $type->toArray());
}
- public function testItMaySetMaxValueAsInt()
+ public function testItMaySetMaxValueAsInt(): void
{
$type = JsonSchema::number()->description('Max price')->max(10);
@@ -53,7 +53,7 @@ public function testItMaySetMaxValueAsInt()
], $type->toArray());
}
- public function testItMaySetDefaultValue()
+ public function testItMaySetDefaultValue(): void
{
$type = JsonSchema::number()->default(9.99);
@@ -63,7 +63,16 @@ public function testItMaySetDefaultValue()
], $type->toArray());
}
- public function testItMaySetMultipleOfAsFloat()
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::number()->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'number',
+ ], JsonSchema::number()->default(null)->toArray());
+ }
+
+ public function testItMaySetMultipleOfAsFloat(): void
{
$type = JsonSchema::number()->multipleOf(0.5);
@@ -73,7 +82,7 @@ public function testItMaySetMultipleOfAsFloat()
], $type->toArray());
}
- public function testItMaySetMultipleOfAsInt()
+ public function testItMaySetMultipleOfAsInt(): void
{
$type = JsonSchema::number()->multipleOf(3);
@@ -83,7 +92,7 @@ public function testItMaySetMultipleOfAsInt()
], $type->toArray());
}
- public function testItMayCombineMultipleOfWithMinAndMax()
+ public function testItMayCombineMultipleOfWithMinAndMax(): void
{
$type = JsonSchema::number()->min(0.0)->max(10.0)->multipleOf(0.25);
@@ -95,7 +104,7 @@ public function testItMayCombineMultipleOfWithMinAndMax()
], $type->toArray());
}
- public function testItMaySetEnum()
+ public function testItMaySetEnum(): void
{
$type = JsonSchema::number()->enum([1, 2.5, 3]);
diff --git a/tests/JsonSchema/ObjectTypeTest.php b/tests/JsonSchema/ObjectTypeTest.php
index b5fdf0ab8..e8407b1e7 100644
--- a/tests/JsonSchema/ObjectTypeTest.php
+++ b/tests/JsonSchema/ObjectTypeTest.php
@@ -7,10 +7,11 @@
use Hypervel\JsonSchema\JsonSchema;
use Hypervel\JsonSchema\JsonSchemaTypeFactory;
use Hypervel\Tests\TestCase;
+use stdClass;
class ObjectTypeTest extends TestCase
{
- public function testItMayNotHaveProperties()
+ public function testItMayNotHaveProperties(): void
{
$type = JsonSchema::object()->title('Payload');
@@ -20,7 +21,7 @@ public function testItMayNotHaveProperties()
], $type->toArray());
}
- public function testItMayBeInitializedWithAClosureButWithoutProperties()
+ public function testItMayBeInitializedWithAClosureButWithoutProperties(): void
{
$type = JsonSchema::object(fn () => [])->title('Payload');
@@ -30,7 +31,7 @@ public function testItMayBeInitializedWithAClosureButWithoutProperties()
], $type->toArray());
}
- public function testItMayHaveProperties()
+ public function testItMayHaveProperties(): void
{
$type = JsonSchema::object([
'age-a' => JsonSchema::integer()->min(0)->required(),
@@ -55,7 +56,7 @@ public function testItMayHaveProperties()
], $type->toArray());
}
- public function testItMayBeInitializedWithAClosureButMayHaveProperties()
+ public function testItMayBeInitializedWithAClosureButMayHaveProperties(): void
{
$type = JsonSchema::object(fn (JsonSchemaTypeFactory $schema) => [
'age-a' => $schema->integer()->min(0)->required(),
@@ -80,7 +81,49 @@ public function testItMayBeInitializedWithAClosureButMayHaveProperties()
], $type->toArray());
}
- public function testItMayDisableAdditionalProperties()
+ public function testNumericStringPropertyNamesRemainStringsInRequiredArray(): void
+ {
+ $type = JsonSchema::object([
+ '1' => JsonSchema::string()->required(),
+ '4' => JsonSchema::string()->required(),
+ ]);
+
+ $array = $type->toArray();
+
+ $this->assertSame(['1', '4'], $array['required']);
+ $this->assertIsString($array['required'][0]);
+ $this->assertIsString($array['required'][1]);
+ }
+
+ public function testListShapedPropertyMapsAreSerializedAsJsonObjects(): void
+ {
+ $zero = JsonSchema::object([
+ '0' => JsonSchema::string()->required(),
+ ])->toArray();
+ $sequential = JsonSchema::object([
+ '0' => JsonSchema::string(),
+ '1' => JsonSchema::integer(),
+ ])->toArray();
+
+ $this->assertInstanceOf(stdClass::class, $zero['properties']);
+ $this->assertSame(['0'], $zero['required']);
+ $this->assertInstanceOf(stdClass::class, $sequential['properties']);
+ $this->assertSame([0, 1], array_keys((array) $sequential['properties']));
+ }
+
+ public function testNonListPropertyMapsRemainArraysAndEncodeAsJsonObjects(): void
+ {
+ $array = JsonSchema::object([
+ '1' => JsonSchema::string(),
+ '4' => JsonSchema::integer(),
+ ])->toArray();
+
+ $this->assertIsArray($array['properties']);
+ $this->assertSame([1, 4], array_keys($array['properties']));
+ $this->assertInstanceOf(stdClass::class, json_decode(json_encode($array))->properties);
+ }
+
+ public function testItMayDisableAdditionalProperties(): void
{
$type = JsonSchema::object()->default(['age' => 1])->withoutAdditionalProperties();
@@ -91,7 +134,29 @@ public function testItMayDisableAdditionalProperties()
], $type->toArray());
}
- public function testItMaySetEnum()
+ public function testListShapedObjectDefaultsAreSerializedAsJsonObjects(): void
+ {
+ $empty = JsonSchema::object()->default([])->toArray();
+ $sequential = JsonSchema::object()->default(['first', 'second'])->toArray();
+ $associative = JsonSchema::object()->default(['name' => 'Taylor'])->toArray();
+
+ $this->assertInstanceOf(stdClass::class, $empty['default']);
+ $this->assertSame([], (array) $empty['default']);
+ $this->assertInstanceOf(stdClass::class, $sequential['default']);
+ $this->assertSame(['first', 'second'], (array) $sequential['default']);
+ $this->assertSame(['name' => 'Taylor'], $associative['default']);
+ }
+
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::object()->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'object',
+ ], JsonSchema::object()->default(null)->toArray());
+ }
+
+ public function testItMaySetEnum(): void
{
$type = JsonSchema::object()->enum([
['a' => 1],
diff --git a/tests/JsonSchema/SerializerTest.php b/tests/JsonSchema/SerializerTest.php
index dd70230ff..239bb4b30 100644
--- a/tests/JsonSchema/SerializerTest.php
+++ b/tests/JsonSchema/SerializerTest.php
@@ -10,7 +10,7 @@
class SerializerTest extends TestCase
{
- public function testItDoesNotKnowHowToSerializeUnknownTypes()
+ public function testItDoesNotKnowHowToSerializeUnknownTypes(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Unsupported [Hypervel\JsonSchema\Types\Type@anonymous');
diff --git a/tests/JsonSchema/StringTypeTest.php b/tests/JsonSchema/StringTypeTest.php
index 8b7a25f7b..a6c9d860a 100644
--- a/tests/JsonSchema/StringTypeTest.php
+++ b/tests/JsonSchema/StringTypeTest.php
@@ -9,7 +9,7 @@
class StringTypeTest extends TestCase
{
- public function testItSetsMinLength()
+ public function testItSetsMinLength(): void
{
$type = (new StringType)->min(5);
@@ -19,7 +19,7 @@ public function testItSetsMinLength()
], $type->toArray());
}
- public function testItSetsMaxLength()
+ public function testItSetsMaxLength(): void
{
$type = (new StringType)->description('User handle')->max(10);
@@ -30,7 +30,7 @@ public function testItSetsMaxLength()
], $type->toArray());
}
- public function testItSetsPattern()
+ public function testItSetsPattern(): void
{
$type = (new StringType)->default('foo')->pattern('^foo.*$');
@@ -41,7 +41,7 @@ public function testItSetsPattern()
], $type->toArray());
}
- public function testItSetsFormat()
+ public function testItSetsFormat(): void
{
$type = (new StringType)->default('foo')->format('date');
@@ -52,7 +52,16 @@ public function testItSetsFormat()
], $type->toArray());
}
- public function testItSetsEnum()
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', (new StringType)->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'string',
+ ], (new StringType)->default(null)->toArray());
+ }
+
+ public function testItSetsEnum(): void
{
$type = (new StringType)->enum(['draft', 'published']);
diff --git a/tests/JsonSchema/TypeTest.php b/tests/JsonSchema/TypeTest.php
index d1e48bc60..df06c7b6c 100644
--- a/tests/JsonSchema/TypeTest.php
+++ b/tests/JsonSchema/TypeTest.php
@@ -10,6 +10,7 @@
use Hypervel\Tests\JsonSchema\Fixtures\Enums\UnitEnum;
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
+use JsonException;
use Opis\JsonSchema\Resolvers\SchemaResolver;
use Opis\JsonSchema\SchemaLoader;
use Opis\JsonSchema\Validator;
@@ -20,7 +21,7 @@
class TypeTest extends TestCase
{
- public function testAsAArrayRepresentation()
+ public function testAsAArrayRepresentation(): void
{
$type = JsonSchema::object([
'age' => JsonSchema::integer()->min(0)->required(),
@@ -41,7 +42,7 @@ public function testAsAArrayRepresentation()
], $type->toArray());
}
- public function testDoesHaveAStringRepresentation()
+ public function testDoesHaveAStringRepresentation(): void
{
$type = JsonSchema::object([
'age' => JsonSchema::integer()->min(0)->required(),
@@ -64,7 +65,7 @@ public function testDoesHaveAStringRepresentation()
JSON, $type->toString());
}
- public function testDoesHaveAStringableRepresentation()
+ public function testDoesHaveAStringableRepresentation(): void
{
$type = JsonSchema::object([
'age' => JsonSchema::integer()->min(0)->required(),
@@ -88,7 +89,7 @@ public function testDoesHaveAStringableRepresentation()
}
#[DataProvider('validSchemasProvider')]
- public function testProducesValidJsonSchemas(Stringable $schema, mixed $data)
+ public function testProducesValidJsonSchemas(Stringable $schema, mixed $data): void
{
$this->assertValidOnJsonSchema($schema, $data);
}
@@ -238,6 +239,9 @@ public static function validSchemasProvider(): array
[JsonSchema::array()->items(JsonSchema::string()->max(3)), ['one', 'two']],
[JsonSchema::array()->default(['x']), ['x']],
[JsonSchema::array()->enum([['a'], ['b', 'c']]), ['b', 'c']],
+ [JsonSchema::array()->unique(), [1, 2, 3]],
+ [JsonSchema::array()->items(JsonSchema::string())->unique(), ['a', 'b', 'c']],
+ [JsonSchema::array()->unique(), []],
// additional ArrayType cases
[JsonSchema::array()->min(0), []], // explicit min zero
[JsonSchema::array()->max(0), []], // exactly zero length
@@ -246,11 +250,35 @@ public static function validSchemasProvider(): array
[JsonSchema::array()->enum([[]]), []],
[JsonSchema::array()->nullable(), null],
[JsonSchema::array()->nullable(false), []],
+
+ // UnionType
+ [JsonSchema::union(['string', 'number']), 'hello'],
+ [JsonSchema::union(['string', 'number']), 42],
+ [JsonSchema::union(['string', 'number']), 3.14],
+ [JsonSchema::union(['integer', 'boolean']), true],
+ [JsonSchema::union(['string', 'number'])->enum(['draft', 5]), 'draft'],
+ [JsonSchema::union(['string', 'number'])->nullable(), null],
+ [JsonSchema::union(['string', 'number'])->nullable(), 'still valid'],
+
+ // AnyOfType
+ [JsonSchema::anyOf([JsonSchema::string(), JsonSchema::integer()]), 'hello'],
+ [JsonSchema::anyOf([JsonSchema::string(), JsonSchema::integer()]), 10],
+ [JsonSchema::anyOf([JsonSchema::string(), JsonSchema::integer()])->nullable(), null],
+ [JsonSchema::anyOf([
+ JsonSchema::object([
+ 'type' => JsonSchema::string()->enum(['card'])->required(),
+ 'last4' => JsonSchema::string()->min(4)->max(4)->required(),
+ ]),
+ JsonSchema::object([
+ 'type' => JsonSchema::string()->enum(['bank_account'])->required(),
+ 'iban' => JsonSchema::string()->required(),
+ ]),
+ ]), (object) ['type' => 'card', 'last4' => '1234']],
];
}
#[DataProvider('invalidSchemasProvider')]
- public function testProducesInvalidJsonSchemas(Stringable $schema, mixed $data)
+ public function testProducesInvalidJsonSchemas(Stringable $schema, mixed $data): void
{
$this->assertNotValidOnJsonSchema($schema, $data);
}
@@ -337,6 +365,8 @@ public static function invalidSchemasProvider(): array
[JsonSchema::array()->max(1), ['a', 'b']], // too many items
[JsonSchema::array()->items(JsonSchema::string()->max(3)), ['four']], // item too long
[JsonSchema::array()->enum([['a'], ['b', 'c']]), ['c', 'd']], // not in enum
+ [JsonSchema::array()->unique(), [1, 1, 2]],
+ [JsonSchema::array()->items(JsonSchema::string())->unique(), ['a', 'b', 'a']],
// additional ArrayType cases
[JsonSchema::array()->items(JsonSchema::integer()), ['a']], // wrong item type
[JsonSchema::array()->min(1), []], // too few
@@ -344,10 +374,31 @@ public static function invalidSchemasProvider(): array
[JsonSchema::array()->enum([['a'], ['b']]), ['a', 'b']], // not equal to any enum member
[JsonSchema::array()->items(JsonSchema::string()->max(1)), ['ab']], // item too long
[JsonSchema::array()->nullable(false), null], // not nullable
+
+ // UnionType
+ [JsonSchema::union(['string', 'number']), true], // boolean not in union
+ [JsonSchema::union(['string', 'number']), []], // array not in union
+ [JsonSchema::union(['string', 'number']), null], // null not allowed unless nullable
+ [JsonSchema::union(['integer', 'boolean']), 'nope'], // string not in union
+ [JsonSchema::union(['string', 'number'])->enum(['draft', 5]), 'archived'], // not in enum
+
+ // AnyOfType
+ [JsonSchema::anyOf([JsonSchema::string(), JsonSchema::integer()]), true],
+ [JsonSchema::anyOf([JsonSchema::string(), JsonSchema::integer()]), null],
+ [JsonSchema::anyOf([
+ JsonSchema::object([
+ 'type' => JsonSchema::string()->enum(['card'])->required(),
+ 'last4' => JsonSchema::string()->min(4)->max(4)->required(),
+ ]),
+ JsonSchema::object([
+ 'type' => JsonSchema::string()->enum(['bank_account'])->required(),
+ 'iban' => JsonSchema::string()->required(),
+ ]),
+ ]), (object) ['type' => 'card', 'iban' => 'wrong-branch']],
];
}
- public function testTypesInObjectSchema()
+ public function testTypesInObjectSchema(): void
{
$schema = JsonSchema::object(fn (JsonSchema $schema): array => [
'name' => $schema->string()->required(),
@@ -357,7 +408,102 @@ public function testTypesInObjectSchema()
$this->assertInstanceOf(JsonSchema::class, $schema);
}
- public function testThrowsWithInvalidEnumString()
+ public function testRequiredMayBeUnset(): void
+ {
+ $schema = JsonSchema::object([
+ 'name' => JsonSchema::string()->required()->required(false),
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => [
+ 'type' => 'string',
+ ],
+ ],
+ ], $schema->toArray());
+
+ $this->assertValidOnJsonSchema($schema, (object) []);
+ }
+
+ public function testNullableMayBeUnset(): void
+ {
+ $schema = JsonSchema::string()->nullable()->nullable(false);
+
+ $this->assertEquals([
+ 'type' => 'string',
+ ], $schema->toArray());
+
+ $this->assertNotValidOnJsonSchema($schema, null);
+ }
+
+ public function testNumericPropertyNamesProduceValidObjectSchemas(): void
+ {
+ $schemas = [
+ [
+ JsonSchema::object(['0' => JsonSchema::string()->required()]),
+ (object) ['0' => 'zero'],
+ ],
+ [
+ JsonSchema::object([
+ '0' => JsonSchema::string()->required(),
+ '1' => JsonSchema::integer()->required(),
+ ]),
+ (object) ['0' => 'zero', '1' => 1],
+ ],
+ [
+ JsonSchema::object([
+ '1' => JsonSchema::string()->required(),
+ '4' => JsonSchema::integer()->required(),
+ ]),
+ (object) ['1' => 'one', '4' => 4],
+ ],
+ ];
+
+ foreach ($schemas as [$schema, $data]) {
+ $this->assertValidOnJsonSchema($schema, $data);
+ }
+ }
+
+ public function testItPreservesAnEmptyEnum(): void
+ {
+ // Opis requires a non-empty enum, but JSON Schema 2020-12 permits an empty unsatisfiable enum.
+ $this->assertSame([
+ 'enum' => [],
+ 'type' => 'string',
+ ], JsonSchema::string()->enum([])->toArray());
+ }
+
+ #[DataProvider('invalidJsonValueProvider')]
+ public function testStringConversionPreservesJsonEncodingFailures(Stringable $schema): void
+ {
+ $this->expectException(JsonException::class);
+
+ $schema->__toString();
+ }
+
+ public static function invalidJsonValueProvider(): array
+ {
+ return [
+ 'invalid UTF-8' => [JsonSchema::string()->default("\xB1\x31")],
+ 'non-finite number' => [JsonSchema::number()->default(INF)],
+ ];
+ }
+
+ public function testStringConversionRejectsResources(): void
+ {
+ $resource = fopen('php://memory', 'r');
+
+ try {
+ $this->expectException(JsonException::class);
+
+ JsonSchema::string()->enum([$resource])->toString();
+ } finally {
+ fclose($resource);
+ }
+ }
+
+ public function testThrowsWithInvalidEnumString(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The provided class must be a BackedEnum.');
@@ -366,7 +512,7 @@ public function testThrowsWithInvalidEnumString()
JsonSchema::string()->enum('NonExistentEnumClass');
}
- public function testThrowsWithNotAnEnumClass()
+ public function testThrowsWithNotAnEnumClass(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The provided class must be a BackedEnum.');
@@ -375,7 +521,7 @@ public function testThrowsWithNotAnEnumClass()
JsonSchema::string()->enum(stdClass::class);
}
- public function testThrowsWithUnitEnumClass()
+ public function testThrowsWithUnitEnumClass(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The provided class must be a BackedEnum.');
From f88ea19ec6ac5fbc34bccbcef855baad208dde51 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:03:57 +0000
Subject: [PATCH 3/9] Add union and any-of schema builders
Expose multi-type unions and constrained any-of alternatives through the JSON Schema factory and contract using concrete, Laravel-style return types.
Normalize null union members into nullability, reject unsupported or non-string members without coercion, preserve member order, and support shared metadata and explicit defaults. Cover direct construction, closures, nullability, failure paths, and round trips.
---
src/contracts/src/JsonSchema/JsonSchema.php | 19 ++-
src/json-schema/src/JsonSchemaTypeFactory.php | 24 +++
src/json-schema/src/Types/AnyOfType.php | 36 +++++
src/json-schema/src/Types/UnionType.php | 74 +++++++++
tests/JsonSchema/AnyOfTypeTest.php | 126 +++++++++++++++
tests/JsonSchema/UnionTypeTest.php | 152 ++++++++++++++++++
6 files changed, 430 insertions(+), 1 deletion(-)
create mode 100644 src/json-schema/src/Types/AnyOfType.php
create mode 100644 src/json-schema/src/Types/UnionType.php
create mode 100644 tests/JsonSchema/AnyOfTypeTest.php
create mode 100644 tests/JsonSchema/UnionTypeTest.php
diff --git a/src/contracts/src/JsonSchema/JsonSchema.php b/src/contracts/src/JsonSchema/JsonSchema.php
index f6ac4f2a8..79749476e 100644
--- a/src/contracts/src/JsonSchema/JsonSchema.php
+++ b/src/contracts/src/JsonSchema/JsonSchema.php
@@ -5,19 +5,22 @@
namespace Hypervel\Contracts\JsonSchema;
use Closure;
+use Hypervel\JsonSchema\Types\AnyOfType;
use Hypervel\JsonSchema\Types\ArrayType;
use Hypervel\JsonSchema\Types\BooleanType;
use Hypervel\JsonSchema\Types\IntegerType;
use Hypervel\JsonSchema\Types\NumberType;
use Hypervel\JsonSchema\Types\ObjectType;
use Hypervel\JsonSchema\Types\StringType;
+use Hypervel\JsonSchema\Types\Type;
+use Hypervel\JsonSchema\Types\UnionType;
interface JsonSchema
{
/**
* Create a new object schema instance.
*
- * @param array|(Closure(JsonSchema): array) $properties
+ * @param array|(Closure(JsonSchema): array) $properties
*/
public function object(Closure|array $properties = []): ObjectType;
@@ -45,4 +48,18 @@ public function number(): NumberType;
* Create a new boolean property instance.
*/
public function boolean(): BooleanType;
+
+ /**
+ * Create a new multi-type union instance.
+ *
+ * @param array $types
+ */
+ public function union(array $types): UnionType;
+
+ /**
+ * Create a new anyOf schema instance.
+ *
+ * @param array|(Closure(JsonSchema): array) $schemas
+ */
+ public function anyOf(Closure|array $schemas): AnyOfType;
}
diff --git a/src/json-schema/src/JsonSchemaTypeFactory.php b/src/json-schema/src/JsonSchemaTypeFactory.php
index c2b94dfff..4d7ce9456 100644
--- a/src/json-schema/src/JsonSchemaTypeFactory.php
+++ b/src/json-schema/src/JsonSchemaTypeFactory.php
@@ -62,4 +62,28 @@ public function boolean(): Types\BooleanType
{
return new Types\BooleanType;
}
+
+ /**
+ * Create a new multi-type union instance.
+ *
+ * @param array $types
+ */
+ public function union(array $types): Types\UnionType
+ {
+ return new Types\UnionType($types);
+ }
+
+ /**
+ * Create a new anyOf schema instance.
+ *
+ * @param array|(Closure(JsonSchemaTypeFactory): array) $schemas
+ */
+ public function anyOf(Closure|array $schemas): Types\AnyOfType
+ {
+ if ($schemas instanceof Closure) {
+ $schemas = $schemas($this);
+ }
+
+ return new Types\AnyOfType($schemas);
+ }
}
diff --git a/src/json-schema/src/Types/AnyOfType.php b/src/json-schema/src/Types/AnyOfType.php
new file mode 100644
index 000000000..ec5dc148b
--- /dev/null
+++ b/src/json-schema/src/Types/AnyOfType.php
@@ -0,0 +1,36 @@
+ $schemas
+ */
+ public function __construct(protected array $schemas)
+ {
+ $this->schemas = array_values($schemas);
+ }
+
+ /**
+ * Get the anyOf schemas.
+ *
+ * @return array
+ */
+ public function schemas(): array
+ {
+ return $this->schemas;
+ }
+
+ /**
+ * Set the type's default value.
+ */
+ public function default(mixed $value): static
+ {
+ return $this->setDefault($value);
+ }
+}
diff --git a/src/json-schema/src/Types/UnionType.php b/src/json-schema/src/Types/UnionType.php
new file mode 100644
index 000000000..622ce372c
--- /dev/null
+++ b/src/json-schema/src/Types/UnionType.php
@@ -0,0 +1,74 @@
+
+ */
+ public const SUPPORTED = ['string', 'integer', 'number', 'boolean', 'object', 'array'];
+
+ /**
+ * The union's member type names.
+ *
+ * @var array
+ */
+ protected array $types;
+
+ /**
+ * Create a new union type instance.
+ *
+ * @param array $types
+ *
+ * @throws InvalidArgumentException
+ */
+ public function __construct(array $types)
+ {
+ $names = [];
+
+ foreach ($types as $name) {
+ if (! is_string($name)) {
+ throw new InvalidArgumentException('Every JSON Schema union member must be a string.');
+ }
+
+ if ($name === 'null') {
+ $this->nullable();
+
+ continue;
+ }
+
+ if (! in_array($name, self::SUPPORTED, true)) {
+ throw new InvalidArgumentException("Unsupported JSON Schema type [{$name}] in a multi-type union.");
+ }
+
+ $names[] = $name;
+ }
+
+ $this->types = array_values(array_unique($names));
+ }
+
+ /**
+ * Get the union's member type names.
+ *
+ * @return array
+ */
+ public function types(): array
+ {
+ return $this->types;
+ }
+
+ /**
+ * Set the type's default value.
+ */
+ public function default(mixed $value): static
+ {
+ return $this->setDefault($value);
+ }
+}
diff --git a/tests/JsonSchema/AnyOfTypeTest.php b/tests/JsonSchema/AnyOfTypeTest.php
new file mode 100644
index 000000000..70b283500
--- /dev/null
+++ b/tests/JsonSchema/AnyOfTypeTest.php
@@ -0,0 +1,126 @@
+title('Identifier');
+
+ $this->assertEquals([
+ 'title' => 'Identifier',
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItMayBeInitializedWithAClosure(): void
+ {
+ $type = JsonSchema::anyOf(fn (JsonSchema $schema): array => [
+ $schema->string(),
+ $schema->integer(),
+ ]);
+
+ $this->assertEquals([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItMayBeNullable(): void
+ {
+ $type = JsonSchema::anyOf([
+ JsonSchema::string(),
+ JsonSchema::integer(),
+ ])->nullable();
+
+ $this->assertEquals([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ['type' => 'null'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testEmptyAnyOfMayBecomeNullable(): void
+ {
+ $this->assertSame([
+ 'anyOf' => [
+ ['type' => 'null'],
+ ],
+ ], JsonSchema::anyOf([])->nullable()->toArray());
+ }
+
+ public function testFinallyEmptyAnyOfIsRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('A JSON Schema anyOf must contain at least one schema.');
+
+ JsonSchema::anyOf([])->toArray();
+ }
+
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::anyOf([JsonSchema::string()])->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'anyOf' => [
+ ['type' => 'string'],
+ ],
+ ], JsonSchema::anyOf([JsonSchema::string()])->default(null)->toArray());
+ }
+
+ public function testItMayDescribeObjectUnions(): void
+ {
+ $type = JsonSchema::anyOf([
+ JsonSchema::object([
+ 'type' => JsonSchema::string()->enum(['article'])->required(),
+ 'title' => JsonSchema::string()->required(),
+ 'content' => JsonSchema::string()->required(),
+ ]),
+ JsonSchema::object([
+ 'type' => JsonSchema::string()->enum(['image'])->required(),
+ 'url' => JsonSchema::string()->required(),
+ 'caption' => JsonSchema::string(),
+ ]),
+ ]);
+
+ $this->assertEquals([
+ 'anyOf' => [
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'type' => ['type' => 'string', 'enum' => ['article']],
+ 'title' => ['type' => 'string'],
+ 'content' => ['type' => 'string'],
+ ],
+ 'required' => ['type', 'title', 'content'],
+ ],
+ [
+ 'type' => 'object',
+ 'properties' => [
+ 'type' => ['type' => 'string', 'enum' => ['image']],
+ 'url' => ['type' => 'string'],
+ 'caption' => ['type' => 'string'],
+ ],
+ 'required' => ['type', 'url'],
+ ],
+ ],
+ ], $type->toArray());
+ }
+}
diff --git a/tests/JsonSchema/UnionTypeTest.php b/tests/JsonSchema/UnionTypeTest.php
new file mode 100644
index 000000000..c02ccd15f
--- /dev/null
+++ b/tests/JsonSchema/UnionTypeTest.php
@@ -0,0 +1,152 @@
+assertEquals([
+ 'type' => ['string', 'number', 'boolean'],
+ ], $type->toArray());
+ }
+
+ public function testSerializesWithMetadata(): void
+ {
+ $type = JsonSchema::union(['string', 'number'])
+ ->title('Value')
+ ->description('A string or a number');
+
+ $this->assertEquals([
+ 'type' => ['string', 'number'],
+ 'title' => 'Value',
+ 'description' => 'A string or a number',
+ ], $type->toArray());
+ }
+
+ public function testDedupesAndPreservesMemberOrder(): void
+ {
+ $type = JsonSchema::union(['number', 'string', 'number', 'boolean', 'string']);
+
+ $this->assertSame(['number', 'string', 'boolean'], $type->types());
+ $this->assertSame(['type' => ['number', 'string', 'boolean']], $type->toArray());
+ }
+
+ public function testAppendsNullWhenNullable(): void
+ {
+ $type = JsonSchema::union(['string', 'number'])->nullable();
+
+ $this->assertEquals([
+ 'type' => ['string', 'number', 'null'],
+ ], $type->toArray());
+ }
+
+ public function testItNormalizesANullMemberIntoNullability(): void
+ {
+ $type = JsonSchema::union(['string', 'number', 'null']);
+
+ $this->assertSame(['string', 'number'], $type->types());
+ $this->assertEquals([
+ 'type' => ['string', 'number', 'null'],
+ ], $type->toArray());
+ }
+
+ public function testANullOnlyUnionSerializesAsANullableUnion(): void
+ {
+ $this->assertSame([
+ 'type' => ['null'],
+ ], JsonSchema::union(['null'])->toArray());
+ }
+
+ public function testItDoesNotDuplicateNullWhenAlreadyNullable(): void
+ {
+ $type = JsonSchema::union(['string', 'null'])->nullable();
+
+ $this->assertEquals([
+ 'type' => ['string', 'null'],
+ ], $type->toArray());
+ }
+
+ public function testEmptyUnionMayBecomeNullable(): void
+ {
+ $this->assertSame([
+ 'type' => ['null'],
+ ], JsonSchema::union([])->nullable()->toArray());
+ }
+
+ public function testFinallyEmptyUnionIsRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('A JSON Schema union must contain at least one type.');
+
+ JsonSchema::union([])->toArray();
+ }
+
+ public function testItDistinguishesAnExplicitNullDefaultFromAnUnsetDefault(): void
+ {
+ $this->assertArrayNotHasKey('default', JsonSchema::union(['string'])->toArray());
+ $this->assertSame([
+ 'default' => null,
+ 'type' => ['string'],
+ ], JsonSchema::union(['string'])->default(null)->toArray());
+ }
+
+ public function testItRejectsAnUnsupportedMemberName(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Unsupported JSON Schema type [wat] in a multi-type union.'));
+
+ JsonSchema::union(['string', 'wat']);
+ }
+
+ #[DataProvider('nonStringMemberProvider')]
+ public function testItRejectsANonStringMember(mixed $member): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Every JSON Schema union member must be a string.'));
+
+ JsonSchema::union(['string', $member]);
+ }
+
+ public static function nonStringMemberProvider(): array
+ {
+ return [
+ 'integer' => [123],
+ 'array' => [['string']],
+ 'object' => [new stdClass],
+ 'null' => [null],
+ 'boolean' => [true],
+ ];
+ }
+
+ public function testItRoundTripsAUnion(): void
+ {
+ $schema = ['type' => ['string', 'number', 'boolean']];
+
+ $type = JsonSchema::fromArray($schema);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame($schema, Serializer::serialize($type));
+ $this->assertEquals($type, JsonSchema::fromArray(Serializer::serialize($type)));
+ }
+
+ public function testItRoundTripsANullableUnion(): void
+ {
+ $schema = ['type' => ['string', 'number', 'null']];
+
+ $type = JsonSchema::fromArray($schema);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame($schema, Serializer::serialize($type));
+ }
+}
From 7519d3d8dfb9374a37564b82a0f68cbc755cd11e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:04:05 +0000
Subject: [PATCH 4/9] Reconstruct supported JSON schemas safely
Add JsonSchema::fromArray() and rebuild the supported JSON Schema 2020-12 subset without silently weakening recognized validation rules or malformed input.
Resolve local references iteratively with per-operation caching, a bounded active path, and a bounded aggregate expansion count. Preserve representable null, composition, enum, default, object-map, and permissive-items forms while rejecting circular, remote, lossy, or structurally conflicting schemas.
Exercise every supported type, nested and referenced schemas, exact round trips, resource bounds, invalid keyword values, unsupported assertions, integer limits, and composition failure paths.
---
src/json-schema/src/Deserializer.php | 849 +++++++++++++
src/json-schema/src/JsonSchema.php | 15 +
tests/JsonSchema/DeserializerTest.php | 1608 +++++++++++++++++++++++++
3 files changed, 2472 insertions(+)
create mode 100644 src/json-schema/src/Deserializer.php
create mode 100644 tests/JsonSchema/DeserializerTest.php
diff --git a/src/json-schema/src/Deserializer.php b/src/json-schema/src/Deserializer.php
new file mode 100644
index 000000000..a2a5202f7
--- /dev/null
+++ b/src/json-schema/src/Deserializer.php
@@ -0,0 +1,849 @@
+
+ */
+ protected const TYPE_SPECIFIC_KEYWORDS = [
+ 'minLength', 'maxLength', 'pattern', 'format',
+ 'minimum', 'maximum', 'multipleOf',
+ 'items', 'minItems', 'maxItems', 'uniqueItems',
+ 'properties', 'required', 'additionalProperties',
+ ];
+
+ /**
+ * The JSON Schema 2020-12 assertions this builder cannot represent.
+ *
+ * @var array
+ */
+ protected const UNSUPPORTED_ASSERTION_KEYWORDS = [
+ 'const', 'not', 'allOf', 'if', 'dependentSchemas', 'dependentRequired',
+ 'prefixItems', 'contains', 'patternProperties', 'propertyNames',
+ 'unevaluatedItems', 'unevaluatedProperties',
+ 'exclusiveMinimum', 'exclusiveMaximum', 'minProperties', 'maxProperties',
+ '$dynamicRef',
+ ];
+
+ /**
+ * The maximum number of schema fragments that may be expanded.
+ */
+ protected const MAX_NODES = 20000;
+
+ /**
+ * The maximum number of distinct references on one active path.
+ */
+ protected const MAX_REFERENCE_DEPTH = 256;
+
+ /**
+ * The number of schema fragments expanded so far.
+ */
+ protected int $nodes = 0;
+
+ /**
+ * The cache of resolved local "$ref" targets, keyed by reference.
+ *
+ * @var array>
+ */
+ protected array $refCache = [];
+
+ /**
+ * Create a new deserializer instance.
+ *
+ * @param array $root
+ */
+ protected function __construct(protected array $root)
+ {
+ }
+
+ /**
+ * Deserialize the supported JSON Schema subset into a type.
+ *
+ * @param array $schema
+ *
+ * @throws InvalidArgumentException
+ */
+ public static function deserialize(array $schema): Types\Type
+ {
+ return (new static($schema))->build($schema);
+ }
+
+ /**
+ * Build a type from the given schema fragment.
+ *
+ * @param array $schema
+ * @param array $refs
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function build(array $schema, array $refs = []): Types\Type
+ {
+ $this->countNode();
+
+ [$schema, $refs] = $this->resolveRef($schema, $refs);
+
+ $this->ensureAssertionsAreSupported($schema);
+
+ if (($type = $this->buildAnyOfComposition($schema, $refs)) !== null) {
+ $this->applyCommon($type, $schema);
+
+ return $type;
+ }
+
+ [$schema, $nullableFromUnion, $refs] = $this->normalizeUnions($schema, $refs);
+
+ if ($nullableFromUnion) {
+ $this->ensureAssertionsAreSupported($schema);
+ }
+
+ [$name, $nullableFromType] = $this->resolveType($schema);
+
+ if (is_array($name)) {
+ $this->ensureUnionConstraintsAreSupported($schema);
+
+ $type = new Types\UnionType($name);
+ } else {
+ $type = match ($name) {
+ 'object' => $this->buildObject($schema, $refs),
+ 'array' => $this->buildArray($schema, $refs),
+ 'string' => $this->buildString($schema),
+ 'integer' => $this->buildInteger($schema),
+ 'number' => $this->buildNumber($schema),
+ 'boolean' => new Types\BooleanType,
+ default => throw new InvalidArgumentException("Unsupported JSON Schema type [{$name}]."),
+ };
+ }
+
+ $this->applyCommon($type, $schema);
+
+ if ($nullableFromUnion || $nullableFromType) {
+ $type->nullable();
+ }
+
+ return $type;
+ }
+
+ /**
+ * Build an anyOf composition unless it is the existing nullable single-schema form.
+ *
+ * @param array $schema
+ * @param array $refs
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function buildAnyOfComposition(array $schema, array $refs = []): ?Types\AnyOfType
+ {
+ if (! array_key_exists('anyOf', $schema)) {
+ return null;
+ }
+
+ if (! is_array($schema['anyOf']) || $schema['anyOf'] === []) {
+ throw new InvalidArgumentException('The JSON Schema [anyOf] keyword must be a non-empty array.');
+ }
+
+ $nullable = false;
+ $branches = [];
+
+ foreach ($schema['anyOf'] as $branch) {
+ if (! is_array($branch)) {
+ throw new InvalidArgumentException('Unable to represent the schema for an anyOf branch; boolean schemas are not supported.');
+ }
+
+ [$branch, $branchRefs] = $this->resolveRef($branch, $refs);
+
+ if ($this->isNullBranch($branch)) {
+ $nullable = true;
+ } else {
+ $branches[] = [$branch, $branchRefs];
+ }
+ }
+
+ if ($nullable && count($branches) === 1) {
+ return null;
+ }
+
+ $this->ensureAnyOfConstraintsAreSupported($schema);
+
+ $type = new Types\AnyOfType(array_map(
+ fn (array $branch) => $this->build($branch[0], $branch[1]),
+ $branches,
+ ));
+
+ if ($nullable) {
+ $type->nullable();
+ }
+
+ return $type;
+ }
+
+ /**
+ * Build an object type from the given schema fragment.
+ *
+ * @param array $schema
+ * @param array $refs
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function buildObject(array $schema, array $refs = []): Types\ObjectType
+ {
+ $properties = [];
+ $definitions = [];
+
+ if (array_key_exists('properties', $schema)) {
+ $definitions = $schema['properties'];
+
+ if (! is_array($definitions) && ! $definitions instanceof stdClass) {
+ throw new InvalidArgumentException('The JSON Schema [properties] keyword must be an object.');
+ }
+ }
+
+ if ($definitions instanceof stdClass) {
+ $definitions = (array) $definitions;
+ }
+
+ $required = [];
+
+ if (array_key_exists('required', $schema)) {
+ if (! is_array($schema['required'])) {
+ throw new InvalidArgumentException('The JSON Schema [required] keyword must be an array of strings.');
+ }
+
+ foreach ($schema['required'] as $name) {
+ if (! is_string($name)) {
+ throw new InvalidArgumentException('The JSON Schema [required] keyword must be an array of strings.');
+ }
+
+ $required[] = $name;
+ }
+ }
+
+ $requiredLookup = array_flip($required);
+
+ foreach ($definitions as $key => $definition) {
+ if (! is_array($definition)) {
+ throw new InvalidArgumentException(
+ "Unable to represent the schema for property [{$key}]; boolean schemas are not supported."
+ );
+ }
+
+ $property = $this->build($definition, $refs);
+
+ if (isset($requiredLookup[(string) $key])) {
+ $property->required();
+ }
+
+ $properties[$key] = $property;
+ }
+
+ foreach ($required as $name) {
+ if (! array_key_exists($name, $properties)) {
+ throw new InvalidArgumentException(
+ "Unable to represent required property [{$name}] because it has no property schema."
+ );
+ }
+ }
+
+ $type = new Types\ObjectType($properties);
+
+ if (array_key_exists('additionalProperties', $schema)) {
+ $additionalProperties = $schema['additionalProperties'];
+
+ if ($additionalProperties === false) {
+ $type->withoutAdditionalProperties();
+ } elseif ($additionalProperties !== true && $additionalProperties !== []) {
+ throw new InvalidArgumentException(
+ 'Schema-valued or malformed JSON Schema [additionalProperties] cannot be represented.'
+ );
+ }
+ }
+
+ return $type;
+ }
+
+ /**
+ * Build an array type from the given schema fragment.
+ *
+ * @param array $schema
+ * @param array $refs
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function buildArray(array $schema, array $refs = []): Types\ArrayType
+ {
+ $type = new Types\ArrayType;
+
+ if (array_key_exists('items', $schema) && $schema['items'] !== true && $schema['items'] !== []) {
+ if (! is_array($schema['items']) || array_is_list($schema['items'])) {
+ throw new InvalidArgumentException(
+ 'The JSON Schema [items] keyword must be true or a single object schema.'
+ );
+ }
+
+ $type->items($this->build($schema['items'], $refs));
+ }
+
+ $type = $this->applyIntegerBounds($type, $schema, 'minItems', 'maxItems');
+
+ if (array_key_exists('uniqueItems', $schema)) {
+ if (! is_bool($schema['uniqueItems'])) {
+ throw new InvalidArgumentException('The JSON Schema [uniqueItems] constraint must be a boolean.');
+ }
+
+ $type->unique($schema['uniqueItems']);
+ }
+
+ return $type;
+ }
+
+ /**
+ * Build a string type from the given schema fragment.
+ *
+ * @param array $schema
+ */
+ protected function buildString(array $schema): Types\StringType
+ {
+ $type = new Types\StringType;
+
+ $type = $this->applyIntegerBounds($type, $schema, 'minLength', 'maxLength');
+
+ if (array_key_exists('pattern', $schema)) {
+ if (! is_string($schema['pattern'])) {
+ throw new InvalidArgumentException('The JSON Schema [pattern] constraint must be a string.');
+ }
+
+ $type->pattern($schema['pattern']);
+ }
+
+ if (array_key_exists('format', $schema)) {
+ if (! is_string($schema['format'])) {
+ throw new InvalidArgumentException('The JSON Schema [format] annotation must be a string.');
+ }
+
+ $type->format($schema['format']);
+ }
+
+ return $type;
+ }
+
+ /**
+ * Build an integer type from the given schema fragment.
+ *
+ * @param array $schema
+ */
+ protected function buildInteger(array $schema): Types\IntegerType
+ {
+ return $this->applyNumericBounds(new Types\IntegerType, $schema, $this->toInteger(...));
+ }
+
+ /**
+ * Build a number type from the given schema fragment.
+ *
+ * @param array $schema
+ */
+ protected function buildNumber(array $schema): Types\NumberType
+ {
+ return $this->applyNumericBounds(new Types\NumberType, $schema);
+ }
+
+ /**
+ * Apply the numeric bound keywords to the given integer or number type.
+ *
+ * @template TType of Types\IntegerType|Types\NumberType
+ *
+ * @param TType $type
+ * @param array $schema
+ * @param null|(callable(float|int): (float|int)) $cast
+ * @return TType
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function applyNumericBounds(Types\IntegerType|Types\NumberType $type, array $schema, ?callable $cast = null): Types\IntegerType|Types\NumberType
+ {
+ $cast ??= static fn (int|float $value) => $value;
+
+ foreach (['minimum' => 'min', 'maximum' => 'max', 'multipleOf' => 'multipleOf'] as $keyword => $method) {
+ if (! array_key_exists($keyword, $schema)) {
+ continue;
+ }
+
+ if (($value = $this->toNumber($schema[$keyword])) === null) {
+ throw new InvalidArgumentException("The JSON Schema [{$keyword}] constraint must be a number.");
+ }
+
+ $type->{$method}($cast($value));
+ }
+
+ return $type;
+ }
+
+ /**
+ * Apply integer-valued minimum and maximum keywords to an array or string type.
+ *
+ * @template TType of Types\ArrayType|Types\StringType
+ *
+ * @param TType $type
+ * @param array $schema
+ * @return TType
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function applyIntegerBounds(Types\ArrayType|Types\StringType $type, array $schema, string $minimumKeyword, string $maximumKeyword): Types\ArrayType|Types\StringType
+ {
+ foreach ([$minimumKeyword => 'min', $maximumKeyword => 'max'] as $keyword => $method) {
+ if (! array_key_exists($keyword, $schema)) {
+ continue;
+ }
+
+ if (($value = $this->toNumber($schema[$keyword])) === null) {
+ throw new InvalidArgumentException("The JSON Schema [{$keyword}] constraint must be an integer.");
+ }
+
+ $type->{$method}($this->toInteger($value));
+ }
+
+ return $type;
+ }
+
+ /**
+ * Apply the keywords shared by every type to the given instance.
+ *
+ * @param array $schema
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function applyCommon(Types\Type $type, array $schema): void
+ {
+ if (array_key_exists('title', $schema)) {
+ if (! is_string($schema['title'])) {
+ throw new InvalidArgumentException('The JSON Schema [title] annotation must be a string.');
+ }
+
+ $type->title($schema['title']);
+ }
+
+ if (array_key_exists('description', $schema)) {
+ if (! is_string($schema['description'])) {
+ throw new InvalidArgumentException('The JSON Schema [description] annotation must be a string.');
+ }
+
+ $type->description($schema['description']);
+ }
+
+ if (array_key_exists('enum', $schema)) {
+ if (! is_array($schema['enum'])) {
+ throw new InvalidArgumentException('The JSON Schema [enum] keyword must be an array.');
+ }
+
+ $type->enum($schema['enum']);
+ }
+
+ if (array_key_exists('default', $schema)) {
+ $default = $schema['default'];
+
+ if ($type instanceof Types\ObjectType && $default instanceof stdClass) {
+ $default = (array) $default;
+ }
+
+ (fn (mixed $value) => $this->setDefault($value))->call($type, $default);
+ }
+ }
+
+ /**
+ * Resolve the base type name and whether the schema is nullable.
+ *
+ * @param array $schema
+ * @return array{0: array|string, 1: bool}
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function resolveType(array $schema): array
+ {
+ $hasType = array_key_exists('type', $schema);
+ $type = $hasType ? $schema['type'] : null;
+ $nullable = false;
+
+ if ($hasType && ! is_string($type) && ! is_array($type)) {
+ throw new InvalidArgumentException('The JSON Schema [type] keyword must be a string or an array of strings.');
+ }
+
+ if ($type === 'null') {
+ return [[], true];
+ }
+
+ if (is_array($type)) {
+ if ($type === []) {
+ throw new InvalidArgumentException('A JSON Schema [type] array must contain at least one type.');
+ }
+
+ foreach ($type as $name) {
+ if (! is_string($name)) {
+ throw new InvalidArgumentException('The JSON Schema [type] keyword must be a string or an array of strings.');
+ }
+ }
+
+ $nullable = in_array('null', $type, true);
+
+ $names = array_values(array_unique(array_filter(
+ $type,
+ static fn (string $value) => $value !== 'null',
+ )));
+
+ if (count($names) > 1) {
+ return [$names, $nullable];
+ }
+
+ if ($names === [] && $nullable) {
+ return [[], true];
+ }
+
+ $type = $names[0] ?? null;
+ }
+
+ $type ??= $this->inferType($schema);
+
+ if (! is_string($type)) {
+ throw new InvalidArgumentException('Unable to determine the JSON Schema type for the given schema.');
+ }
+
+ return [$type, $nullable];
+ }
+
+ /**
+ * Infer the type name when "type" is absent but the shape is unambiguous.
+ *
+ * @param array $schema
+ */
+ protected function inferType(array $schema): ?string
+ {
+ return match (true) {
+ array_key_exists('properties', $schema), array_key_exists('additionalProperties', $schema), array_key_exists('required', $schema) => 'object',
+ array_key_exists('items', $schema), array_key_exists('minItems', $schema), array_key_exists('maxItems', $schema), array_key_exists('uniqueItems', $schema) => 'array',
+ array_key_exists('enum', $schema) && is_array($schema['enum']) => $this->inferEnumType($schema['enum']),
+ array_key_exists('minLength', $schema), array_key_exists('maxLength', $schema), array_key_exists('pattern', $schema), array_key_exists('format', $schema) => 'string',
+ array_key_exists('minimum', $schema), array_key_exists('maximum', $schema), array_key_exists('multipleOf', $schema) => 'number',
+ default => null,
+ };
+ }
+
+ /**
+ * Infer the scalar type shared by a homogeneous enum of scalars.
+ *
+ * @param array $enum
+ */
+ protected function inferEnumType(array $enum): ?string
+ {
+ $resolved = null;
+
+ foreach ($enum as $value) {
+ $current = match (true) {
+ is_bool($value) => 'boolean',
+ is_int($value) => 'integer',
+ is_float($value) => 'number',
+ is_string($value) => 'string',
+ default => null,
+ };
+
+ if ($current === null) {
+ return null;
+ }
+
+ if ($resolved === null || $resolved === $current) {
+ $resolved = $current;
+
+ continue;
+ }
+
+ // A mix of integers and floats is still numeric; anything else is ambiguous...
+ if (in_array($resolved, ['integer', 'number'], true) && in_array($current, ['integer', 'number'], true)) {
+ $resolved = 'number';
+
+ continue;
+ }
+
+ return null;
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * Ensure a multi-type union carries no type-specific constraint keywords.
+ *
+ * @param array $schema
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function ensureUnionConstraintsAreSupported(array $schema): void
+ {
+ $unsupported = array_values(array_intersect(static::TYPE_SPECIFIC_KEYWORDS, array_keys($schema)));
+
+ if ($unsupported !== []) {
+ throw new InvalidArgumentException(
+ 'Type-specific keywords [' . implode(', ', $unsupported) . '] are not supported on a JSON Schema union.'
+ );
+ }
+ }
+
+ /**
+ * Ensure the schema carries no standard assertions this builder cannot represent.
+ *
+ * @param array $schema
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function ensureAssertionsAreSupported(array $schema): void
+ {
+ $unsupported = array_values(array_intersect(static::UNSUPPORTED_ASSERTION_KEYWORDS, array_keys($schema)));
+
+ if ($unsupported !== []) {
+ throw new InvalidArgumentException(
+ 'Unsupported JSON Schema assertion keywords [' . implode(', ', $unsupported) . '] cannot be represented.'
+ );
+ }
+ }
+
+ /**
+ * Ensure a general anyOf composition carries no competing structural constraints.
+ *
+ * @param array $schema
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function ensureAnyOfConstraintsAreSupported(array $schema): void
+ {
+ $keywords = [...static::TYPE_SPECIFIC_KEYWORDS, 'type', 'oneOf'];
+ $unsupported = array_values(array_intersect($keywords, array_keys($schema)));
+
+ if ($unsupported !== []) {
+ throw new InvalidArgumentException(
+ 'Structural keywords [' . implode(', ', $unsupported) . '] are not supported alongside a general JSON Schema anyOf.'
+ );
+ }
+ }
+
+ /**
+ * Collapse "anyOf" / "oneOf" null branches into a single effective schema.
+ *
+ * @param array $schema
+ * @param array $refs
+ * @return array{0: array, 1: bool, 2: array}
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function normalizeUnions(array $schema, array $refs = []): array
+ {
+ foreach (['anyOf', 'oneOf'] as $key) {
+ if (! array_key_exists($key, $schema)) {
+ continue;
+ }
+
+ if (! is_array($schema[$key]) || $schema[$key] === []) {
+ throw new InvalidArgumentException("The JSON Schema [{$key}] keyword must be a non-empty array.");
+ }
+
+ $nullable = false;
+ $branches = [];
+
+ foreach ($schema[$key] as $branch) {
+ if (! is_array($branch)) {
+ throw new InvalidArgumentException(
+ "Unable to represent the schema for a {$key} branch; boolean schemas are not supported."
+ );
+ }
+
+ [$branch, $branchRefs] = $this->resolveRef($branch, $refs);
+
+ if ($this->isNullBranch($branch)) {
+ $nullable = true;
+ } else {
+ $branches[] = [$branch, $branchRefs];
+ }
+ }
+
+ if (! $nullable || count($branches) !== 1) {
+ throw new InvalidArgumentException(
+ "Only a nullable \"{$key}\" (a single schema plus a bare \"null\" branch) is supported."
+ );
+ }
+
+ [$branch, $branchRefs] = $branches[0];
+
+ $siblings = $schema;
+ unset($siblings[$key]);
+
+ foreach ($siblings as $siblingKey => $value) {
+ if (array_key_exists($siblingKey, $branch) && $branch[$siblingKey] !== $value) {
+ throw new InvalidArgumentException(
+ "Conflicting [{$siblingKey}] between a \"{$key}\" branch and its sibling keys."
+ );
+ }
+ }
+
+ $merged = array_merge($siblings, $branch);
+ $compositions = array_values(array_intersect(['anyOf', 'oneOf'], array_keys($merged)));
+
+ if ($compositions !== []) {
+ throw new InvalidArgumentException(
+ 'Structural keywords [' . implode(', ', $compositions)
+ . "] are not supported alongside a nullable \"{$key}\"."
+ );
+ }
+
+ return [$merged, true, $branchRefs];
+ }
+
+ return [$schema, false, $refs];
+ }
+
+ /**
+ * Determine if the given schema branch describes only the "null" type.
+ *
+ * @param array $branch
+ */
+ protected function isNullBranch(array $branch): bool
+ {
+ if (count($branch) !== 1 || ! array_key_exists('type', $branch)) {
+ return false;
+ }
+
+ $type = $branch['type'] ?? null;
+
+ return $type === 'null' || $type === ['null'];
+ }
+
+ /**
+ * Resolve a local "$ref" against the root schema, merging sibling keys.
+ *
+ * @param array $schema
+ * @param array $refs
+ * @return array{0: array, 1: array}
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function resolveRef(array $schema, array $refs = []): array
+ {
+ while (array_key_exists('$ref', $schema)) {
+ if (! is_string($schema['$ref'])) {
+ throw new InvalidArgumentException('The JSON Schema [$ref] keyword must be a string.');
+ }
+
+ $ref = $schema['$ref'];
+
+ if (in_array($ref, $refs, true)) {
+ throw new InvalidArgumentException("Circular JSON Schema \$ref [{$ref}] detected.");
+ }
+
+ if (count($refs) >= static::MAX_REFERENCE_DEPTH) {
+ throw new InvalidArgumentException(
+ 'JSON Schema reference paths may not contain more than ' . static::MAX_REFERENCE_DEPTH . ' distinct references.'
+ );
+ }
+
+ $this->countNode();
+ $refs[] = $ref;
+
+ $resolved = $this->lookupRef($ref);
+ unset($schema['$ref']);
+ $schema = array_merge($resolved, $schema);
+ }
+
+ return [$schema, $refs];
+ }
+
+ /**
+ * Look up a local JSON pointer reference within the root schema.
+ *
+ * @return array
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function lookupRef(string $ref): array
+ {
+ if (isset($this->refCache[$ref])) {
+ return $this->refCache[$ref];
+ }
+
+ if ($ref === '#') {
+ return $this->refCache[$ref] = $this->root;
+ }
+
+ if (! str_starts_with($ref, '#/')) {
+ throw new InvalidArgumentException("Unable to resolve non-local JSON Schema \$ref [{$ref}].");
+ }
+
+ $target = $this->root;
+
+ foreach (explode('/', substr($ref, 2)) as $segment) {
+ $segment = str_replace(['~1', '~0'], ['/', '~'], rawurldecode($segment));
+
+ if (! is_array($target) || ! array_key_exists($segment, $target)) {
+ throw new InvalidArgumentException("Unable to resolve JSON Schema \$ref [{$ref}].");
+ }
+
+ $target = $target[$segment];
+ }
+
+ if (! is_array($target)) {
+ throw new InvalidArgumentException("The JSON Schema \$ref [{$ref}] does not point to a schema.");
+ }
+
+ return $this->refCache[$ref] = $target;
+ }
+
+ /**
+ * Normalize the given value to an integer or float, or null when not numeric.
+ */
+ protected function toNumber(mixed $value): int|float|null
+ {
+ if (is_int($value) || is_float($value)) {
+ return $value;
+ }
+
+ if (is_string($value) && is_numeric($value)) {
+ return $value + 0;
+ }
+
+ return null;
+ }
+
+ /**
+ * Normalize the given number to a PHP integer.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function toInteger(int|float $value): int
+ {
+ if (is_float($value) && floor($value) !== $value) {
+ throw new InvalidArgumentException("The JSON Schema integer constraint [{$value}] must be an integer.");
+ }
+
+ if (is_float($value) && ($value < (float) PHP_INT_MIN || $value >= (float) PHP_INT_MAX)) {
+ throw new InvalidArgumentException('The JSON Schema integer constraint is outside the PHP integer range.');
+ }
+
+ return (int) $value;
+ }
+
+ /**
+ * Count an expanded schema fragment.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function countNode(): void
+ {
+ if (++$this->nodes > static::MAX_NODES) {
+ throw new InvalidArgumentException(
+ 'JSON Schema reconstruction exceeded the maximum expansion of ' . static::MAX_NODES . ' schema fragments.'
+ );
+ }
+ }
+}
diff --git a/src/json-schema/src/JsonSchema.php b/src/json-schema/src/JsonSchema.php
index ffb64eceb..d2dded713 100644
--- a/src/json-schema/src/JsonSchema.php
+++ b/src/json-schema/src/JsonSchema.php
@@ -6,17 +6,32 @@
use Closure;
use Hypervel\JsonSchema\Types\Type;
+use InvalidArgumentException;
/**
* @method static Types\ObjectType object(Closure|array $properties = [])
+ * @method static Types\AnyOfType anyOf(Closure|array $schemas)
* @method static Types\IntegerType integer()
* @method static Types\NumberType number()
* @method static Types\StringType string()
* @method static Types\BooleanType boolean()
* @method static Types\ArrayType array()
+ * @method static Types\UnionType union(array $types)
*/
class JsonSchema
{
+ /**
+ * Build a type from a raw array of the Hypervel-supported JSON Schema subset.
+ *
+ * @param array $schema
+ *
+ * @throws InvalidArgumentException
+ */
+ public static function fromArray(array $schema): Type
+ {
+ return Deserializer::deserialize($schema);
+ }
+
/**
* Dynamically pass static methods to the schema instance.
*/
diff --git a/tests/JsonSchema/DeserializerTest.php b/tests/JsonSchema/DeserializerTest.php
new file mode 100644
index 000000000..e7bad81e3
--- /dev/null
+++ b/tests/JsonSchema/DeserializerTest.php
@@ -0,0 +1,1608 @@
+ JsonSchema::string()->min(1)->max(50)->pattern('^[a-z]+$')->required(),
+ 'age' => JsonSchema::integer()->min(0)->max(120)->default(18),
+ 'score' => JsonSchema::number()->min(0)->max(100)->multipleOf(0.5),
+ 'active' => JsonSchema::boolean()->default(true),
+ 'tags' => JsonSchema::array()->items(JsonSchema::string()->max(20))->min(1)->max(5)->unique(),
+ 'meta' => JsonSchema::object([
+ 'created' => JsonSchema::string()->format('date-time')->required(),
+ ])->withoutAdditionalProperties(),
+ 'status' => JsonSchema::string()->enum(['draft', 'published'])->nullable(),
+ ])->title('User')->description('A user payload');
+
+ $array = Serializer::serialize($type);
+
+ $rebuilt = JsonSchema::fromArray($array);
+
+ $this->assertInstanceOf(ObjectType::class, $rebuilt);
+ $this->assertSame($array, Serializer::serialize($rebuilt));
+ $this->assertEquals($type, $rebuilt);
+ }
+
+ public function testItMapsEverySupportedType(): void
+ {
+ $this->assertInstanceOf(ObjectType::class, JsonSchema::fromArray(['type' => 'object']));
+ $this->assertInstanceOf(ArrayType::class, JsonSchema::fromArray(['type' => 'array']));
+ $this->assertInstanceOf(StringType::class, JsonSchema::fromArray(['type' => 'string']));
+ $this->assertInstanceOf(IntegerType::class, JsonSchema::fromArray(['type' => 'integer']));
+ $this->assertInstanceOf(NumberType::class, JsonSchema::fromArray(['type' => 'number']));
+ $this->assertInstanceOf(BooleanType::class, JsonSchema::fromArray(['type' => 'boolean']));
+ }
+
+ public function testItAppliesStringConstraints(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'string',
+ 'minLength' => 2,
+ 'maxLength' => 8,
+ 'pattern' => '^foo.*$',
+ 'format' => 'email',
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'string',
+ 'minLength' => 2,
+ 'maxLength' => 8,
+ 'pattern' => '^foo.*$',
+ 'format' => 'email',
+ ], $type->toArray());
+ }
+
+ public function testItAppliesIntegerConstraints(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'maximum' => 100,
+ 'multipleOf' => 5,
+ ]);
+
+ $this->assertInstanceOf(IntegerType::class, $type);
+ $this->assertEquals([
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'maximum' => 100,
+ 'multipleOf' => 5,
+ ], $type->toArray());
+ }
+
+ public function testItPreservesPhpIntegerConstraintBoundaries(): void
+ {
+ $array = JsonSchema::fromArray([
+ 'type' => 'integer',
+ 'minimum' => PHP_INT_MIN,
+ 'maximum' => PHP_INT_MAX,
+ ])->toArray();
+
+ $this->assertSame(PHP_INT_MIN, $array['minimum']);
+ $this->assertSame(PHP_INT_MAX, $array['maximum']);
+ }
+
+ public function testItPreservesTheRepresentableIntegralFloatBoundary(): void
+ {
+ $array = JsonSchema::fromArray([
+ 'type' => 'integer',
+ 'minimum' => (float) PHP_INT_MIN,
+ ])->toArray();
+
+ $this->assertSame(PHP_INT_MIN, $array['minimum']);
+ }
+
+ #[DataProvider('outOfRangeIntegerConstraintProvider')]
+ public function testItRejectsIntegerConstraintsOutsideThePhpIntegerRange(int|float|string $value): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'The JSON Schema integer constraint is outside the PHP integer range.'
+ ));
+
+ JsonSchema::fromArray([
+ 'type' => 'integer',
+ 'minimum' => $value,
+ ]);
+ }
+
+ public static function outOfRangeIntegerConstraintProvider(): array
+ {
+ return [
+ 'positive integral float' => [(float) PHP_INT_MAX],
+ 'negative integral float' => [-1e20],
+ 'large exponent' => [1e100],
+ 'numeric string' => ['9223372036854775808'],
+ ];
+ }
+
+ public function testItAppliesNumberConstraintsAndPreservesFloats(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'number',
+ 'minimum' => 0.5,
+ 'maximum' => 9.9,
+ 'multipleOf' => 0.1,
+ ]);
+
+ $this->assertInstanceOf(NumberType::class, $type);
+
+ $array = $type->toArray();
+
+ $this->assertSame(0.5, $array['minimum']);
+ $this->assertSame(9.9, $array['maximum']);
+ $this->assertSame(0.1, $array['multipleOf']);
+ }
+
+ public function testItAppliesArrayConstraintsAndNestedItems(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'array',
+ 'items' => ['type' => 'string', 'maxLength' => 3],
+ 'minItems' => 1,
+ 'maxItems' => 4,
+ 'uniqueItems' => true,
+ ]);
+
+ $this->assertInstanceOf(ArrayType::class, $type);
+ $this->assertEquals([
+ 'type' => 'array',
+ 'minItems' => 1,
+ 'maxItems' => 4,
+ 'items' => [
+ 'type' => 'string',
+ 'maxLength' => 3,
+ ],
+ 'uniqueItems' => true,
+ ], $type->toArray());
+ }
+
+ public function testItPreservesNumericStringIntegerValuedConstraints(): void
+ {
+ $array = JsonSchema::fromArray([
+ 'type' => 'array',
+ 'minItems' => '3',
+ ])->toArray();
+ $string = JsonSchema::fromArray([
+ 'type' => 'string',
+ 'maxLength' => '4',
+ ])->toArray();
+
+ $this->assertSame(3, $array['minItems']);
+ $this->assertSame(4, $string['maxLength']);
+ }
+
+ #[DataProvider('malformedIntegerValuedConstraintProvider')]
+ public function testItRejectsMalformedIntegerValuedConstraints(array $schema, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ JsonSchema::fromArray($schema);
+ }
+
+ public static function malformedIntegerValuedConstraintProvider(): array
+ {
+ return [
+ 'minItems overflow' => [['type' => 'array', 'minItems' => 1e20], 'The JSON Schema integer constraint is outside the PHP integer range.'],
+ 'maxItems overflow' => [['type' => 'array', 'maxItems' => 1e100], 'The JSON Schema integer constraint is outside the PHP integer range.'],
+ 'minLength overflow' => [['type' => 'string', 'minLength' => 1e20], 'The JSON Schema integer constraint is outside the PHP integer range.'],
+ 'maxLength overflow' => [['type' => 'string', 'maxLength' => 1e100], 'The JSON Schema integer constraint is outside the PHP integer range.'],
+ 'fractional' => [['type' => 'array', 'minItems' => 2.7], 'The JSON Schema integer constraint [2.7] must be an integer.'],
+ 'nonnumeric' => [['type' => 'array', 'minItems' => 'abc'], 'The JSON Schema [minItems] constraint must be an integer.'],
+ 'boolean' => [['type' => 'string', 'minLength' => true], 'The JSON Schema [minLength] constraint must be an integer.'],
+ 'array' => [['type' => 'string', 'maxLength' => ['x']], 'The JSON Schema [maxLength] constraint must be an integer.'],
+ 'null' => [['type' => 'array', 'maxItems' => null], 'The JSON Schema [maxItems] constraint must be an integer.'],
+ ];
+ }
+
+ #[DataProvider('permissiveItemsProvider')]
+ public function testItAcceptsRepresentablePermissiveItems(mixed $items): void
+ {
+ $this->assertSame(['type' => 'array'], JsonSchema::fromArray([
+ 'type' => 'array',
+ 'items' => $items,
+ ])->toArray());
+ }
+
+ public static function permissiveItemsProvider(): array
+ {
+ return [
+ 'true' => [true],
+ 'empty schema' => [[]],
+ ];
+ }
+
+ public function testItRejectsFalseItems(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'The JSON Schema [items] keyword must be true or a single object schema.'
+ ));
+
+ JsonSchema::fromArray([
+ 'type' => 'array',
+ 'items' => false,
+ ]);
+ }
+
+ public function testItBuildsNestedObjectsAndMarksRequiredChildren(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string', 'minLength' => 1],
+ 'age' => ['type' => 'integer', 'minimum' => 0],
+ 'address' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'city' => ['type' => 'string'],
+ ],
+ 'required' => ['city'],
+ ],
+ ],
+ 'required' => ['name'],
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string', 'minLength' => 1],
+ 'age' => ['type' => 'integer', 'minimum' => 0],
+ 'address' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'city' => ['type' => 'string'],
+ ],
+ 'required' => ['city'],
+ ],
+ ],
+ 'required' => ['name'],
+ ], $type->toArray());
+ }
+
+ public function testItPreservesNumericStringPropertyNamesWhenMarkingRequired(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ '1' => ['type' => 'string'],
+ '4' => ['type' => 'string'],
+ ],
+ 'required' => ['1', '4'],
+ ]);
+
+ $array = $type->toArray();
+
+ $this->assertEquals(['1', '4'], $array['required']);
+ $this->assertIsString($array['required'][0]);
+ }
+
+ public function testItDisallowsAdditionalPropertiesWhenFalse(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'additionalProperties' => false,
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'object',
+ 'additionalProperties' => false,
+ ], $type->toArray());
+ }
+
+ #[DataProvider('permissiveAdditionalPropertiesProvider')]
+ public function testItAcceptsRepresentablePermissiveAdditionalProperties(array $schema): void
+ {
+ $this->assertSame(['type' => 'object'], JsonSchema::fromArray($schema)->toArray());
+ }
+
+ public static function permissiveAdditionalPropertiesProvider(): array
+ {
+ return [
+ 'absent' => [['type' => 'object']],
+ 'true' => [['type' => 'object', 'additionalProperties' => true]],
+ 'empty schema' => [['type' => 'object', 'additionalProperties' => []]],
+ ];
+ }
+
+ #[DataProvider('unsupportedAdditionalPropertiesProvider')]
+ public function testItRejectsAdditionalPropertiesItCannotRepresent(mixed $additionalProperties): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Schema-valued or malformed JSON Schema [additionalProperties] cannot be represented.');
+
+ JsonSchema::fromArray([
+ 'type' => 'object',
+ 'additionalProperties' => $additionalProperties,
+ ]);
+ }
+
+ public static function unsupportedAdditionalPropertiesProvider(): array
+ {
+ return [
+ 'schema' => [['type' => 'string']],
+ 'object' => [new stdClass],
+ 'null' => [null],
+ 'scalar' => ['no'],
+ ];
+ }
+
+ public function testItRejectsARequiredNameWithoutAPropertySchema(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unable to represent required property [missing] because it has no property schema.');
+
+ JsonSchema::fromArray([
+ 'type' => 'object',
+ 'required' => ['missing'],
+ ]);
+ }
+
+ public function testItAcceptsSerializerEmittedObjectPropertyMaps(): void
+ {
+ $properties = new stdClass;
+ $properties->{'0'} = ['type' => 'string'];
+
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => $properties,
+ 'required' => ['0'],
+ ]);
+
+ $serialized = $type->toArray();
+
+ $this->assertInstanceOf(stdClass::class, $serialized['properties']);
+ $this->assertSame(['0'], $serialized['required']);
+ }
+
+ public function testItRoundTripsSerializerEmittedObjectMapsAndDefaults(): void
+ {
+ $serialized = JsonSchema::object([
+ '0' => JsonSchema::string()->required(),
+ '1' => JsonSchema::integer(),
+ ])->default(['zero', 1])->toArray();
+
+ $rebuilt = JsonSchema::fromArray($serialized)->toArray();
+
+ $this->assertEquals($serialized, $rebuilt);
+ $this->assertInstanceOf(stdClass::class, $rebuilt['properties']);
+ $this->assertInstanceOf(stdClass::class, $rebuilt['default']);
+ }
+
+ public function testItNormalizesNullableFromATypeArray(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => ['string', 'null'],
+ 'minLength' => 1,
+ ]);
+
+ $this->assertInstanceOf(StringType::class, $type);
+ $this->assertEquals([
+ 'type' => ['string', 'null'],
+ 'minLength' => 1,
+ ], $type->toArray());
+ }
+
+ public function testItNormalizesNullableFromAnAnyOfNullBranch(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'title' => 'Nickname',
+ 'anyOf' => [
+ ['type' => 'string', 'minLength' => 1],
+ ['type' => 'null'],
+ ],
+ ]);
+
+ $this->assertInstanceOf(StringType::class, $type);
+ $this->assertEquals([
+ 'title' => 'Nickname',
+ 'minLength' => 1,
+ 'type' => ['string', 'null'],
+ ], $type->toArray());
+ }
+
+ public function testItNormalizesNullableFromAOneOfNullBranch(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'oneOf' => [
+ ['type' => 'null'],
+ ['type' => 'integer', 'minimum' => 0],
+ ],
+ ]);
+
+ $this->assertInstanceOf(IntegerType::class, $type);
+ $this->assertEquals([
+ 'minimum' => 0,
+ 'type' => ['integer', 'null'],
+ ], $type->toArray());
+ }
+
+ public function testItResolvesALocalRefAgainstDefs(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'author' => ['$ref' => '#/$defs/User'],
+ ],
+ 'required' => ['author'],
+ '$defs' => [
+ 'User' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ 'required' => ['name'],
+ ],
+ ],
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'author' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ 'required' => ['name'],
+ ],
+ ],
+ 'required' => ['author'],
+ ], $type->toArray());
+ }
+
+ public function testItResolvesALocalRefAgainstDefinitions(): void
+ {
+ $type = JsonSchema::fromArray([
+ '$ref' => '#/definitions/Tag',
+ 'definitions' => [
+ 'Tag' => ['type' => 'string', 'minLength' => 1],
+ ],
+ ]);
+
+ $this->assertInstanceOf(StringType::class, $type);
+ $this->assertEquals([
+ 'type' => 'string',
+ 'minLength' => 1,
+ ], $type->toArray());
+ }
+
+ public function testItMergesSiblingKeysOverARef(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'handle' => [
+ '$ref' => '#/$defs/Name',
+ 'description' => 'Overridden description',
+ ],
+ ],
+ '$defs' => [
+ 'Name' => [
+ 'type' => 'string',
+ 'description' => 'Original description',
+ 'minLength' => 1,
+ ],
+ ],
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'handle' => [
+ 'description' => 'Overridden description',
+ 'minLength' => 1,
+ 'type' => 'string',
+ ],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItMergesEveryLevelOfAReferenceChainWithOuterSiblingsWinning(): void
+ {
+ $type = JsonSchema::fromArray([
+ '$ref' => '#/$defs/outer',
+ 'title' => 'Outermost title',
+ '$defs' => [
+ 'outer' => [
+ '$ref' => '#/$defs/target',
+ 'title' => 'Intermediate title',
+ 'description' => 'Intermediate description',
+ ],
+ 'target' => [
+ 'type' => 'string',
+ 'title' => 'Target title',
+ 'description' => 'Target description',
+ 'minLength' => 1,
+ ],
+ ],
+ ]);
+
+ $this->assertSame([
+ 'title' => 'Outermost title',
+ 'description' => 'Intermediate description',
+ 'minLength' => 1,
+ 'type' => 'string',
+ ], $type->toArray());
+ }
+
+ public function testItResolvesEscapedJsonPointerSegments(): void
+ {
+ $type = JsonSchema::fromArray([
+ '$ref' => '#/$defs/a~1b~0c',
+ '$defs' => [
+ 'a/b~c' => ['type' => 'string'],
+ ],
+ ]);
+
+ $this->assertSame(['type' => 'string'], $type->toArray());
+ }
+
+ public function testItThrowsForAnUnresolvableRef(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Unable to resolve JSON Schema $ref [#/$defs/Missing].'));
+
+ JsonSchema::fromArray([
+ '$ref' => '#/$defs/Missing',
+ '$defs' => [],
+ ]);
+ }
+
+ public function testItThrowsForARemoteRef(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Unable to resolve non-local JSON Schema $ref [https://example.com/user.json].'));
+
+ JsonSchema::fromArray([
+ '$ref' => 'https://example.com/user.json',
+ ]);
+ }
+
+ public function testItInfersObjectTypeFromProperties(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ ]);
+
+ $this->assertInstanceOf(ObjectType::class, $type);
+ }
+
+ public function testItInfersArrayTypeFromItems(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'items' => ['type' => 'integer'],
+ ]);
+
+ $this->assertInstanceOf(ArrayType::class, $type);
+ $this->assertEquals([
+ 'type' => 'array',
+ 'items' => ['type' => 'integer'],
+ ], $type->toArray());
+ }
+
+ public function testItInfersScalarTypeFromAHomogeneousEnum(): void
+ {
+ $this->assertInstanceOf(StringType::class, JsonSchema::fromArray([
+ 'enum' => ['draft', 'published'],
+ ]));
+
+ $this->assertInstanceOf(IntegerType::class, JsonSchema::fromArray([
+ 'enum' => [1, 2, 3],
+ ]));
+
+ $this->assertInstanceOf(NumberType::class, JsonSchema::fromArray([
+ 'enum' => [1, 2.5, 3],
+ ]));
+
+ $this->assertInstanceOf(BooleanType::class, JsonSchema::fromArray([
+ 'enum' => [true, false],
+ ]));
+ }
+
+ public function testItAppliesEnumAndDefault(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'string',
+ 'enum' => ['draft', 'published'],
+ 'default' => 'draft',
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'string',
+ 'default' => 'draft',
+ 'enum' => ['draft', 'published'],
+ ], $type->toArray());
+ }
+
+ public function testItIgnoresUnknownKeywords(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'string',
+ 'minLength' => 1,
+ '$schema' => 'https://json-schema.org/draft/2020-12/schema',
+ '$comment' => 'ignore me',
+ 'readOnly' => true,
+ 'contentEncoding' => 'base64',
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'string',
+ 'minLength' => 1,
+ ], $type->toArray());
+ }
+
+ public function testItThrowsWhenTheTypeCannotBeDetermined(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Unable to determine the JSON Schema type for the given schema.'));
+
+ JsonSchema::fromArray([
+ 'title' => 'Mystery',
+ ]);
+ }
+
+ public function testItDetectsACircularRefInsteadOfRecursing(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Circular JSON Schema $ref [#/$defs/node] detected.'));
+
+ JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'children' => ['type' => 'array', 'items' => ['$ref' => '#/$defs/node']],
+ ],
+ '$defs' => [
+ 'node' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'children' => ['type' => 'array', 'items' => ['$ref' => '#/$defs/node']],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ public function testItResolvesTheSameRefUsedInSiblingPositions(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'home' => ['$ref' => '#/$defs/address'],
+ 'work' => ['$ref' => '#/$defs/address'],
+ ],
+ '$defs' => [
+ 'address' => ['type' => 'object', 'properties' => ['city' => ['type' => 'string']]],
+ ],
+ ]);
+
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'home' => ['type' => 'object', 'properties' => ['city' => ['type' => 'string']]],
+ 'work' => ['type' => 'object', 'properties' => ['city' => ['type' => 'string']]],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testReferenceDepthIsScopedToOneActivePath(): void
+ {
+ $type = JsonSchemaDepthLimitedDeserializer::deserialize([
+ 'type' => 'object',
+ 'properties' => [
+ 'left' => ['$ref' => '#/$defs/value'],
+ 'right' => ['$ref' => '#/$defs/value'],
+ ],
+ '$defs' => [
+ 'value' => ['type' => 'string'],
+ ],
+ ]);
+
+ $this->assertInstanceOf(ObjectType::class, $type);
+ }
+
+ #[DataProvider('overlyDeepReferenceProvider')]
+ public function testItRejectsOverlyDeepActiveReferencePaths(array $schema): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('JSON Schema reference paths may not contain more than 1 distinct references.');
+
+ JsonSchemaDepthLimitedDeserializer::deserialize($schema);
+ }
+
+ public static function overlyDeepReferenceProvider(): array
+ {
+ $definitions = [
+ 'first' => ['$ref' => '#/$defs/second'],
+ 'second' => ['type' => 'string'],
+ ];
+
+ return [
+ 'direct' => [[
+ '$ref' => '#/$defs/first',
+ '$defs' => $definitions,
+ ]],
+ 'nested' => [[
+ 'type' => 'object',
+ 'properties' => [
+ 'value' => ['$ref' => '#/$defs/first'],
+ ],
+ '$defs' => $definitions,
+ ]],
+ ];
+ }
+
+ public function testReferenceFollowsConsumeTheTotalExpansionBudget(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('JSON Schema reconstruction exceeded the maximum expansion of 1 schema fragments.');
+
+ JsonSchemaNodeLimitedDeserializer::deserialize([
+ '$ref' => '#/$defs/value',
+ '$defs' => [
+ 'value' => ['type' => 'string'],
+ ],
+ ]);
+ }
+
+ public function testItDeserializesAMultiTypeUnion(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => ['string', 'number', 'boolean'],
+ ]);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame(['string', 'number', 'boolean'], $type->types());
+ $this->assertSame(['type' => ['string', 'number', 'boolean']], $type->toArray());
+ }
+
+ public function testItDeserializesANullableMultiTypeUnion(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => ['string', 'number', 'null'],
+ ]);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame(['string', 'number'], $type->types());
+ $this->assertSame(['type' => ['string', 'number', 'null']], $type->toArray());
+ }
+
+ public function testItDoesNotTreatASingleTypePlusNullAsAUnion(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => ['string', 'null'],
+ ]);
+
+ $this->assertInstanceOf(StringType::class, $type);
+ $this->assertSame(['type' => ['string', 'null']], $type->toArray());
+ }
+
+ public function testItDedupesAndPreservesOrderOfUnionMembers(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => ['number', 'string', 'number', 'boolean', 'string'],
+ ]);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame(['number', 'string', 'boolean'], $type->types());
+ }
+
+ public function testItDeserializesAUnionNestedInAnObjectProperty(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'value' => ['type' => ['string', 'number']],
+ ],
+ ]);
+
+ $this->assertInstanceOf(ObjectType::class, $type);
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'value' => ['type' => ['string', 'number']],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItDeserializesAUnionNestedInArrayItems(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'array',
+ 'items' => ['type' => ['string', 'integer', 'null']],
+ ]);
+
+ $this->assertInstanceOf(ArrayType::class, $type);
+ $this->assertEquals([
+ 'type' => 'array',
+ 'items' => ['type' => ['string', 'integer', 'null']],
+ ], $type->toArray());
+ }
+
+ public function testItDeserializesAnAnyOfComposition(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'title' => 'Identifier',
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ]);
+
+ $this->assertInstanceOf(AnyOfType::class, $type);
+ $this->assertEquals([
+ 'title' => 'Identifier',
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItDeserializesANullableAnyOfComposition(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ['type' => 'null'],
+ ],
+ ]);
+
+ $this->assertInstanceOf(AnyOfType::class, $type);
+ $this->assertEquals([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ['type' => 'null'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItDeserializesANullOnlyAnyOfComposition(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'null'],
+ ],
+ ]);
+
+ $this->assertInstanceOf(AnyOfType::class, $type);
+ $this->assertSame([
+ 'anyOf' => [
+ ['type' => 'null'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItPreservesAnnotationsOnAGeneralAnyOf(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'title' => 'Identifier',
+ 'description' => 'A string or integer identifier.',
+ 'default' => null,
+ 'enum' => ['default', 1],
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ]);
+
+ $this->assertSame([
+ 'title' => 'Identifier',
+ 'description' => 'A string or integer identifier.',
+ 'default' => null,
+ 'enum' => ['default', 1],
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testNullableSingleSchemaCompositionsKeepSupportedSiblingConstraints(): void
+ {
+ foreach (['anyOf', 'oneOf'] as $keyword) {
+ $type = JsonSchema::fromArray([
+ $keyword => [
+ ['type' => 'string'],
+ ['type' => 'null'],
+ ],
+ 'minLength' => 2,
+ ]);
+
+ $this->assertSame([
+ 'minLength' => 2,
+ 'type' => ['string', 'null'],
+ ], $type->toArray());
+ }
+ }
+
+ #[DataProvider('emptyInputCompositionProvider')]
+ public function testItRejectsEmptyInputCompositions(string $keyword): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("The JSON Schema [{$keyword}] keyword must be a non-empty array.");
+
+ JsonSchema::fromArray([$keyword => []]);
+ }
+
+ public static function emptyInputCompositionProvider(): array
+ {
+ return [
+ 'anyOf' => ['anyOf'],
+ 'oneOf' => ['oneOf'],
+ ];
+ }
+
+ #[DataProvider('booleanOneOfBranchProvider')]
+ public function testItRejectsBooleanOneOfBranches(bool $branch): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'Unable to represent the schema for a oneOf branch; boolean schemas are not supported.'
+ ));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ $branch,
+ ['type' => 'string'],
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public static function booleanOneOfBranchProvider(): array
+ {
+ return [
+ 'true' => [true],
+ 'false' => [false],
+ ];
+ }
+
+ #[DataProvider('survivingCompositionProvider')]
+ public function testItRejectsACompositionThatSurvivesNullableCollapse(array $schema, string $keyword, string $composition): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(
+ "Structural keywords [{$keyword}] are not supported alongside a nullable \"{$composition}\"."
+ );
+
+ JsonSchema::fromArray($schema);
+ }
+
+ public static function survivingCompositionProvider(): array
+ {
+ return [
+ 'oneOf sibling of anyOf' => [[
+ 'anyOf' => [['type' => 'string'], ['type' => 'null']],
+ 'oneOf' => [['type' => 'integer'], ['type' => 'null']],
+ ], 'oneOf', 'anyOf'],
+ 'oneOf inside anyOf branch' => [[
+ 'anyOf' => [
+ ['type' => 'string', 'oneOf' => [['type' => 'integer']]],
+ ['type' => 'null'],
+ ],
+ ], 'oneOf', 'anyOf'],
+ 'anyOf inside oneOf branch' => [[
+ 'oneOf' => [
+ ['type' => 'string', 'anyOf' => [['type' => 'integer']]],
+ ['type' => 'null'],
+ ],
+ ], 'anyOf', 'oneOf'],
+ 'anyOf sibling of oneOf' => [[
+ 'oneOf' => [['type' => 'string'], ['type' => 'null']],
+ 'anyOf' => [['type' => 'integer'], ['type' => 'null']],
+ ], 'oneOf', 'anyOf'],
+ 'oneOf inside referenced anyOf branch' => [[
+ 'anyOf' => [['$ref' => '#/$defs/value'], ['type' => 'null']],
+ '$defs' => [
+ 'value' => ['type' => 'string', 'oneOf' => [['type' => 'integer']]],
+ ],
+ ], 'oneOf', 'anyOf'],
+ 'anyOf inside anyOf branch' => [[
+ 'anyOf' => [
+ ['type' => 'string', 'anyOf' => [['type' => 'integer']]],
+ ['type' => 'null'],
+ ],
+ ], 'anyOf', 'anyOf'],
+ ];
+ }
+
+ #[DataProvider('unsupportedAnyOfSiblingProvider')]
+ public function testGeneralAnyOfRejectsCompetingStructuralKeywords(string $keyword, mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("Structural keywords [{$keyword}] are not supported alongside a general JSON Schema anyOf.");
+
+ JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ $keyword => $value,
+ ]);
+ }
+
+ public static function unsupportedAnyOfSiblingProvider(): array
+ {
+ return [
+ 'type-specific keyword' => ['minLength', 1],
+ 'type' => ['type', 'string'],
+ 'oneOf' => ['oneOf', [['type' => 'string']]],
+ ];
+ }
+
+ public function testItDeserializesAnAnyOfNestedInAnObjectProperty(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'value' => [
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ],
+ ],
+ 'required' => ['value'],
+ ]);
+
+ $this->assertInstanceOf(ObjectType::class, $type);
+ $this->assertEquals([
+ 'type' => 'object',
+ 'properties' => [
+ 'value' => [
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ],
+ ],
+ 'required' => ['value'],
+ ], $type->toArray());
+ }
+
+ public function testItThrowsForAnUnsupportedUnionMember(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Unsupported JSON Schema type [wat] in a multi-type union.'));
+
+ JsonSchema::fromArray([
+ 'type' => ['string', 'wat'],
+ ]);
+ }
+
+ public function testItThrowsForANonStringUnionMember(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'The JSON Schema [type] keyword must be a string or an array of strings.'
+ ));
+
+ JsonSchema::fromArray([
+ 'type' => ['string', 123],
+ ]);
+ }
+
+ public function testItThrowsWhenAUnionCarriesTypeSpecificKeywords(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Type-specific keywords [items] are not supported on a JSON Schema union.'));
+
+ JsonSchema::fromArray([
+ 'type' => ['array', 'string'],
+ 'items' => ['type' => 'integer'],
+ ]);
+ }
+
+ public function testItRejectsAnEmptyTypeArray(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('A JSON Schema [type] array must contain at least one type.');
+
+ JsonSchema::fromArray(['type' => []]);
+ }
+
+ public function testItReconstructsABareNullOnlyTypeArray(): void
+ {
+ $type = JsonSchema::fromArray(['type' => ['null']]);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame(['type' => ['null']], $type->toArray());
+ }
+
+ public function testItReconstructsAScalarNullType(): void
+ {
+ $type = JsonSchema::fromArray(['type' => 'null']);
+
+ $this->assertInstanceOf(UnionType::class, $type);
+ $this->assertSame(['type' => ['null']], $type->toArray());
+ }
+
+ public function testItReconstructsAScalarNullObjectProperty(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'value' => ['type' => 'null'],
+ ],
+ 'required' => ['value'],
+ ]);
+
+ $this->assertSame([
+ 'properties' => [
+ 'value' => ['type' => ['null']],
+ ],
+ 'type' => 'object',
+ 'required' => ['value'],
+ ], $type->toArray());
+ }
+
+ public function testItReconstructsScalarNullArrayItems(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'array',
+ 'items' => ['type' => 'null'],
+ ]);
+
+ $this->assertSame([
+ 'items' => ['type' => ['null']],
+ 'type' => 'array',
+ ], $type->toArray());
+ }
+
+ public function testItReconstructsAScalarNullRefTarget(): void
+ {
+ $type = JsonSchema::fromArray([
+ '$ref' => '#/$defs/nothing',
+ '$defs' => [
+ 'nothing' => ['type' => 'null'],
+ ],
+ ]);
+
+ $this->assertSame(['type' => ['null']], $type->toArray());
+ }
+
+ public function testItPreservesAnnotationsOnANullOnlyTypeArray(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => ['null'],
+ 'title' => 'No value',
+ 'default' => null,
+ ]);
+
+ $this->assertSame([
+ 'title' => 'No value',
+ 'default' => null,
+ 'type' => ['null'],
+ ], $type->toArray());
+ }
+
+ public function testItPreservesAnnotationsOnAScalarNullType(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'null',
+ 'title' => 'No value',
+ 'default' => null,
+ ]);
+
+ $this->assertSame([
+ 'title' => 'No value',
+ 'default' => null,
+ 'type' => ['null'],
+ ], $type->toArray());
+ }
+
+ public function testItRejectsTypeSpecificConstraintsOnANullOnlyTypeArray(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Type-specific keywords [minLength] are not supported on a JSON Schema union.');
+
+ JsonSchema::fromArray([
+ 'type' => ['null'],
+ 'minLength' => 1,
+ ]);
+ }
+
+ public function testItRejectsTypeSpecificConstraintsOnAScalarNullType(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Type-specific keywords [minLength] are not supported on a JSON Schema union.');
+
+ JsonSchema::fromArray([
+ 'type' => 'null',
+ 'minLength' => 1,
+ ]);
+ }
+
+ public function testItPreservesAConstrainedNullBranchInAGeneralAnyOf(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'null', 'title' => 'No value', 'enum' => ['never']],
+ ],
+ ]);
+
+ $this->assertInstanceOf(AnyOfType::class, $type);
+ $this->assertSame([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['title' => 'No value', 'enum' => ['never'], 'type' => ['null']],
+ ],
+ ], $type->toArray());
+ }
+
+ public function testItRejectsANonBareNullBranchInOneOf(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Only a nullable "oneOf" (a single schema plus a bare "null" branch) is supported.');
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ ['type' => 'string'],
+ ['type' => 'null', 'title' => 'No value'],
+ ],
+ ]);
+ }
+
+ public function testItRejectsATypeSpecificKeywordOnANonBareNullBranch(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Type-specific keywords [minLength] are not supported on a JSON Schema union.');
+
+ JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'null', 'minLength' => 2],
+ ],
+ ]);
+ }
+
+ #[DataProvider('unsupportedAssertionProvider')]
+ public function testItRejectsUnsupportedJsonSchema202012Assertions(array $schema, string $keyword): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage("Unsupported JSON Schema assertion keywords [{$keyword}] cannot be represented.");
+
+ JsonSchema::fromArray($schema);
+ }
+
+ public static function unsupportedAssertionProvider(): array
+ {
+ return [
+ 'const' => [['type' => 'string', 'const' => 'fixed'], 'const'],
+ 'not' => [['type' => 'string', 'not' => ['const' => 'x']], 'not'],
+ 'allOf' => [['type' => 'string', 'allOf' => [['minLength' => 2]]], 'allOf'],
+ 'if' => [['type' => 'string', 'if' => ['minLength' => 2]], 'if'],
+ 'dependentSchemas' => [['type' => 'object', 'dependentSchemas' => ['a' => ['required' => ['b']]]], 'dependentSchemas'],
+ 'dependentRequired' => [['type' => 'object', 'dependentRequired' => ['a' => ['b']]], 'dependentRequired'],
+ 'prefixItems' => [['type' => 'array', 'prefixItems' => [['type' => 'string']]], 'prefixItems'],
+ 'contains' => [['type' => 'array', 'contains' => ['type' => 'string'], 'minContains' => 2], 'contains'],
+ 'patternProperties' => [['type' => 'object', 'patternProperties' => []], 'patternProperties'],
+ 'propertyNames' => [['type' => 'object', 'propertyNames' => ['pattern' => '^[a-z]+$']], 'propertyNames'],
+ 'unevaluatedItems' => [['type' => 'array', 'unevaluatedItems' => false], 'unevaluatedItems'],
+ 'unevaluatedProperties' => [['type' => 'object', 'unevaluatedProperties' => false], 'unevaluatedProperties'],
+ 'exclusiveMinimum' => [['type' => 'number', 'exclusiveMinimum' => 0], 'exclusiveMinimum'],
+ 'exclusiveMaximum' => [['type' => 'number', 'exclusiveMaximum' => 10], 'exclusiveMaximum'],
+ 'minProperties' => [['type' => 'object', 'minProperties' => 1], 'minProperties'],
+ 'maxProperties' => [['type' => 'object', 'maxProperties' => 1], 'maxProperties'],
+ '$dynamicRef' => [['$dynamicRef' => '#/$defs/value', '$defs' => ['value' => ['type' => 'string']]], '$dynamicRef'],
+ ];
+ }
+
+ #[DataProvider('ignoredNoOpOrAnnotationProvider')]
+ public function testItContinuesToIgnoreNoOpCompanionKeywordsAnnotationsAndExtensions(array $schema, array $expected): void
+ {
+ $this->assertSame($expected, JsonSchema::fromArray($schema)->toArray());
+ }
+
+ public static function ignoredNoOpOrAnnotationProvider(): array
+ {
+ return [
+ 'then' => [['type' => 'string', 'then' => ['maxLength' => 1]], ['type' => 'string']],
+ 'else' => [['type' => 'string', 'else' => ['maxLength' => 1]], ['type' => 'string']],
+ 'minContains' => [['type' => 'array', 'minContains' => 2], ['type' => 'array']],
+ 'maxContains' => [['type' => 'array', 'maxContains' => 2], ['type' => 'array']],
+ 'annotation' => [['type' => 'string', 'readOnly' => true], ['type' => 'string']],
+ 'extension' => [['type' => 'string', 'x-internal' => true], ['type' => 'string']],
+ ];
+ }
+
+ public function testItRejectsAnUnsupportedAssertionReachedThroughARef(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unsupported JSON Schema assertion keywords [const] cannot be represented.');
+
+ JsonSchema::fromArray([
+ '$ref' => '#/$defs/value',
+ '$defs' => [
+ 'value' => ['type' => 'string', 'const' => 'fixed'],
+ ],
+ ]);
+ }
+
+ public function testItRejectsAnUnsupportedAssertionMergedFromANullableCompositionBranch(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unsupported JSON Schema assertion keywords [const] cannot be represented.');
+
+ JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string', 'const' => 'fixed'],
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public function testItRejectsAnUnsupportedAssertionOnAGeneralAnyOfBranch(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unsupported JSON Schema assertion keywords [const] cannot be represented.');
+
+ JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string', 'const' => 'fixed'],
+ ['type' => 'integer'],
+ ],
+ ]);
+ }
+
+ public function testItRejectsAnExplicitNullTypeInsteadOfInferringAnotherType(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'The JSON Schema [type] keyword must be a string or an array of strings.'
+ ));
+
+ JsonSchema::fromArray([
+ 'type' => null,
+ 'minLength' => 2,
+ ]);
+ }
+
+ #[DataProvider('nonStringReferenceProvider')]
+ public function testItRejectsANonStringReferenceInsteadOfDroppingIt(array $schema): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'The JSON Schema [$ref] keyword must be a string.'
+ ));
+
+ JsonSchema::fromArray($schema);
+ }
+
+ public static function nonStringReferenceProvider(): array
+ {
+ return [
+ 'direct null reference' => [[
+ '$ref' => null,
+ 'type' => 'string',
+ ]],
+ 'non-string reference revealed by a preceding reference' => [[
+ '$ref' => '#/$defs/value',
+ '$defs' => [
+ 'value' => ['$ref' => 123, 'type' => 'string'],
+ ],
+ ]],
+ ];
+ }
+
+ #[DataProvider('malformedRecognizedKeywordProvider')]
+ public function testItRejectsMalformedRecognizedKeywordValues(array $schema, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ JsonSchema::fromArray($schema);
+ }
+
+ public static function malformedRecognizedKeywordProvider(): array
+ {
+ return [
+ 'type array member' => [['type' => ['string', 123]], 'The JSON Schema [type] keyword must be a string or an array of strings.'],
+ 'pattern' => [['type' => 'string', 'pattern' => ['^a']], 'The JSON Schema [pattern] constraint must be a string.'],
+ 'format' => [['type' => 'string', 'format' => ['date']], 'The JSON Schema [format] annotation must be a string.'],
+ 'title' => [['type' => 'string', 'title' => ['Name']], 'The JSON Schema [title] annotation must be a string.'],
+ 'description' => [['type' => 'string', 'description' => ['Name']], 'The JSON Schema [description] annotation must be a string.'],
+ 'uniqueItems' => [['type' => 'array', 'uniqueItems' => 'false'], 'The JSON Schema [uniqueItems] constraint must be a boolean.'],
+ 'required' => [['type' => 'object', 'required' => 'name'], 'The JSON Schema [required] keyword must be an array of strings.'],
+ 'required member' => [[
+ 'type' => 'object',
+ 'properties' => ['0' => ['type' => 'string']],
+ 'required' => [0],
+ ], 'The JSON Schema [required] keyword must be an array of strings.'],
+ 'properties' => [['type' => 'object', 'properties' => 'oops'], 'The JSON Schema [properties] keyword must be an object.'],
+ 'anyOf' => [['type' => 'string', 'anyOf' => 'oops'], 'The JSON Schema [anyOf] keyword must be a non-empty array.'],
+ 'oneOf' => [['type' => 'string', 'oneOf' => 'oops'], 'The JSON Schema [oneOf] keyword must be a non-empty array.'],
+ 'numeric null' => [['type' => 'number', 'minimum' => null], 'The JSON Schema [minimum] constraint must be a number.'],
+ 'items null' => [['type' => 'array', 'items' => null], 'The JSON Schema [items] keyword must be true or a single object schema.'],
+ ];
+ }
+
+ #[DataProvider('nullRecognizedKeywordWithoutTypeProvider')]
+ public function testItRoutesNullRecognizedKeywordsWithoutATypeToTheirOwningGuard(array $schema, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ JsonSchema::fromArray($schema);
+ }
+
+ public static function nullRecognizedKeywordWithoutTypeProvider(): array
+ {
+ return [
+ 'properties' => [['properties' => null, 'minLength' => 2], 'The JSON Schema [properties] keyword must be an object.'],
+ 'required' => [['required' => null, 'minLength' => 2], 'The JSON Schema [required] keyword must be an array of strings.'],
+ 'items' => [['items' => null, 'minLength' => 2], 'The JSON Schema [items] keyword must be true or a single object schema.'],
+ 'uniqueItems' => [['uniqueItems' => null, 'minLength' => 2], 'The JSON Schema [uniqueItems] constraint must be a boolean.'],
+ 'minLength' => [['minLength' => null], 'The JSON Schema [minLength] constraint must be an integer.'],
+ 'minimum' => [['minimum' => null], 'The JSON Schema [minimum] constraint must be a number.'],
+ 'enum' => [['enum' => null, 'minLength' => 2], 'The JSON Schema [enum] keyword must be an array.'],
+ ];
+ }
+
+ public function testItThrowsForABooleanPropertySchema(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Unable to represent the schema for property [meta]; boolean schemas are not supported.'));
+
+ JsonSchema::fromArray([
+ 'type' => 'object',
+ 'properties' => [
+ 'meta' => true,
+ ],
+ ]);
+ }
+
+ public function testItThrowsForANonNumericNumericConstraint(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('The JSON Schema [minimum] constraint must be a number.'));
+
+ JsonSchema::fromArray([
+ 'type' => 'number',
+ 'minimum' => 'oops',
+ ]);
+ }
+
+ public function testItThrowsForANonIntegerIntegerConstraint(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('The JSON Schema integer constraint [1.9] must be an integer.'));
+
+ JsonSchema::fromArray([
+ 'type' => 'integer',
+ 'minimum' => 1.9,
+ ]);
+ }
+
+ public function testItThrowsForTupleItems(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'The JSON Schema [items] keyword must be true or a single object schema.'
+ ));
+
+ JsonSchema::fromArray([
+ 'type' => 'array',
+ 'items' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ]);
+ }
+
+ public function testItThrowsWhenAUnionBranchConflictsWithSiblingKeys(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Conflicting [type] between a "anyOf" branch and its sibling keys.'));
+
+ JsonSchema::fromArray([
+ 'type' => 'integer',
+ 'anyOf' => [
+ ['type' => 'string', 'minLength' => 3],
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public function testItThrowsForAnUnsupportedOneOfUnion(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Only a nullable "oneOf" (a single schema plus a bare "null" branch) is supported.'));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ ['type' => 'string'],
+ ['type' => 'integer'],
+ ],
+ ]);
+ }
+
+ public function testItPreservesAnExplicitNullDefault(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'string',
+ 'default' => null,
+ ]);
+
+ $this->assertSame([
+ 'default' => null,
+ 'type' => 'string',
+ ], $type->toArray());
+ }
+
+ public function testItNormalizesAnObjectDefaultEmittedAsAJsonObject(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'type' => 'object',
+ 'default' => (object) [],
+ ]);
+
+ $serialized = $type->toArray();
+
+ $this->assertInstanceOf(stdClass::class, $serialized['default']);
+ $this->assertSame([], (array) $serialized['default']);
+ }
+
+ #[DataProvider('malformedEnumProvider')]
+ public function testItRejectsAMalformedEnum(mixed $enum): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The JSON Schema [enum] keyword must be an array.');
+
+ JsonSchema::fromArray([
+ 'type' => 'string',
+ 'enum' => $enum,
+ ]);
+ }
+
+ public static function malformedEnumProvider(): array
+ {
+ return [
+ 'string' => ['draft'],
+ 'object' => [new stdClass],
+ 'integer' => [1],
+ 'null' => [null],
+ ];
+ }
+
+ public function testItPreservesAnEmptyEnum(): void
+ {
+ // Opis requires a non-empty enum, but JSON Schema 2020-12 permits an empty unsatisfiable enum.
+ $this->assertSame([
+ 'enum' => [],
+ 'type' => 'string',
+ ], JsonSchema::fromArray([
+ 'type' => 'string',
+ 'enum' => [],
+ ])->toArray());
+ }
+
+ public function testItResolvesTheRootRefPointer(): void
+ {
+ // "#" resolves to the root, so a self-reference is detected as circular...
+ $this->expectExceptionObject(new InvalidArgumentException('Circular JSON Schema $ref [#] detected.'));
+
+ JsonSchema::fromArray(['$ref' => '#']);
+ }
+}
+
+class JsonSchemaDepthLimitedDeserializer extends Deserializer
+{
+ protected const MAX_REFERENCE_DEPTH = 1;
+}
+
+class JsonSchemaNodeLimitedDeserializer extends Deserializer
+{
+ protected const MAX_NODES = 1;
+}
From c9bacfd5065ea579fab901c662220cf963181133 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:04:11 +0000
Subject: [PATCH 5/9] Publish the JSON Schema documentation
Add the JSON Schema guide to the framework documentation navigation and link the package README to the canonical user documentation.
Record the public differences from Laravel that developers must account for, including explicit null defaults, sum-type defaults, strict reconstruction failures, supported null and items forms, and bounded local reference expansion.
---
src/boost/docs/documentation.md | 1 +
src/json-schema/README.md | 8 +++++++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/boost/docs/documentation.md b/src/boost/docs/documentation.md
index 270b89ac1..dd6ae594b 100644
--- a/src/boost/docs/documentation.md
+++ b/src/boost/docs/documentation.md
@@ -49,6 +49,7 @@
- [File Storage](/docs/{{version}}/filesystem)
- [Helpers](/docs/{{version}}/helpers)
- [HTTP Client](/docs/{{version}}/http-client)
+ - [JSON Schema](/docs/{{version}}/json-schema)
- [Localization](/docs/{{version}}/localization)
- [Mail](/docs/{{version}}/mail)
- [Notifications](/docs/{{version}}/notifications)
diff --git a/src/json-schema/README.md b/src/json-schema/README.md
index 5591bff30..68c666fac 100644
--- a/src/json-schema/README.md
+++ b/src/json-schema/README.md
@@ -1,4 +1,10 @@
JSON Schema for Hypervel
===
-[](https://deepwiki.com/hypervel/json-schema)
\ No newline at end of file
+Documentation: https://hypervel.org/docs/json-schema
+
+## Differences From Laravel
+
+Hypervel accepts explicit `null` defaults and provides fluent defaults for union and any-of schemas. Invalid JSON values and finally-empty compositions throw instead of producing unusable output. `fromArray()` also rejects malformed or unsupported JSON Schema 2020-12 assertions it cannot preserve, supports scalar and array-form null-only schemas and permissive `items: true`, and bounds both reference depth and total expansion.
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/JsonSchema
From 32c74b388d586a8401b12429b3ce50931e5c2be3 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:04:17 +0000
Subject: [PATCH 6/9] Record the completed JSON Schema audit
Mark the package complete in the framework audit checklist and route future readers to the dedicated JSON Schema plan and ledger entry.
Record the accepted findings, rejected speculative mechanisms, lifecycle and performance assessment, regression coverage, upstream handoff, validation results, and final no-debt disposition.
---
...amework-coroutine-state-lifecycle-audit.md | 8 +++---
...-coroutine-state-lifecycle-audit-ledger.md | 26 +++++++++++++++++++
2 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md
index 50ec755ac..4eaeb2539 100644
--- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md
@@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** None. `socialite` is complete; detail plan `2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md`.
-- **Ledger entries required for the active work:** None. The completed Socialite work is recorded under `Complete Socialite correctness, first-party extensibility, and lifecycle`, with its cross-package findings recorded at their owning package entries.
-- **Pending revalidation carried into the active work:** None. Socialite revalidated `support-02` and completed `support-34`, `object-pool-04`, and `reverb-40` at their owning boundaries.
+- **Active package or work unit:** None. `json-schema` is complete; detail plan `2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md`.
+- **Ledger entries required for the active work:** None. The completed JSON Schema work is recorded under `Complete JSON Schema correctness, current parity, and bounded reconstruction`.
+- **Pending revalidation carried into the active work:** None. JSON Schema changed no lower-level assumption and has no repository runtime consumer requiring revalidation.
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1317,7 +1317,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
- [ ] `sentry`
- [ ] `inertia`
- [x] `nested-set`
-- [ ] `json-schema`
+- [x] `json-schema`
### Tooling and developer surfaces
diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
index 0b6ec4c96..2906bd93e 100644
--- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -1966,3 +1966,29 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Performance and complexity:** Ordinary non-Socialite requests are unchanged. Provider construction adds one non-yielding integer increment, request paths add bounded local array/string checks beside existing network work, and JWKS reuse removes repeated network requests while bounding headerless reuse to five minutes by default and retaining one key set and one refresh timestamp per provider. No request path gains a lock, timer, background job, registry, unbounded map, clone, container lookup, serialization layer, or additional ordinary network round trip.
- **Validation and review:** Changed tests passed during implementation; focused Socialite, Support, Object Pool, and Reverb coverage, root and split Composer validation, facade and documentation checks, stale-symbol scans, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, and `git diff --check` passed. Review independently reproduced the provider-namespace collision, stale config rebinding, nonce-disabled failure, partial-user memoization, and response-state risks, then signed off after every source and plan correction landed.
- **Assessment:** Socialite is coroutine-safe, worker-lifecycle-aware, protocol-correct, current at the supported Laravel surface, and first-party extensible without ecosystem-manager machinery. Every accepted finding is fixed at its lowest owner; no stale response state, compatibility workaround, speculative abstraction, unresolved accepted defect, meaningful performance regression, or deferred TODO remains.
+
+### Complete JSON Schema correctness, current parity, and bounded reconstruction
+
+- **Status and inspected surface:** Complete; implementation, the authoritative gate, fresh source/test/documentation review, and independent code review are signed off. The audit covered every JSON Schema source and test file, the contract, package metadata, repository consumers, current Laravel 13.x source/tests and originating changes, JSON Schema 2020-12 representation rules, Opis validation behavior, reference expansion, serializer wire shapes, and worker-failure boundaries. The detailed design is recorded in [`2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md`](2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md).
+
+| Findings | Final decision |
+|---|---|
+| `json-schema-01` | Port current Laravel `unique`, reversible flags, `fromArray`, union, and any-of APIs with truthful Hypervel typing. |
+| `json-schema-02` | Replace recursive reference following with a local loop, cap one active path at 256 distinct references, and bound aggregate expansion without shared state. |
+| `json-schema-03`, `json-schema-11`, `json-schema-12` | Reject malformed, unsupported, coerced, dropped, deferred, or structurally lossy reconstruction; preserve representable permissive forms, empty enums, and bare null schemas. |
+| `json-schema-04` | Emit numeric/list-shaped property maps and top-level object defaults as JSON objects and normalize those exact boundaries during reconstruction. |
+| `json-schema-05` | Preserve JSON encoding failures with `JSON_THROW_ON_ERROR` instead of returning a blank schema. |
+| `json-schema-06` | Validate union and any-of only after nullability is applied, reject finally-empty output and empty input type arrays, and reconstruct null-only schemas truthfully. |
+| `json-schema-07`, `json-schema-08` | Add canonical package guidance and provenance, and distinguish required properties from nullable values. |
+| `json-schema-09`, `json-schema-10` | Preserve explicit null concrete defaults and add owner-approved fluent mixed defaults to union and any-of schemas. |
+| `json-schema-13` | Reject non-string direct union members without coercion, warnings, or undocumented PHP errors. |
+
+- **Architecture and lifecycle:** Factories, builders, serialization, and deserialization remain allocation-only and operation-local. Serializer retains one protected static list of ignored internal fields, inherited from Laravel and never mutated by package code; no other static or worker-lifetime state exists. No provider, binding, callback, external service, coroutine context, or worker cleanup is involved. Reference caches and counters exist only for one `fromArray()` call.
+- **Reference-risk evidence:** The recursive implementation expanded about 130 KiB of 4,000-link input to 192 MiB and died near 200 KiB / 6,000 links at a 256 MiB limit. The iterative implementation handled 6,000 links in about 8 MiB and 79 ms. The original memory fatal is uncatchable and would terminate a Swoole worker and every concurrent coroutine it serves.
+- **Implementation:** The current builder surface, sum types, explicit-default state, reversible flags, unique arrays, and strict direct-union boundary are complete. Serialization preserves object shapes, numeric required names, final composition validity, and encoding exceptions. Reconstruction uses bounded iterative local references, strict recognized-keyword ownership, exact null/composition handling, and fail-closed unsupported assertions while continuing to ignore harmless annotations and extensions. Superseded coercion, recursive reference frames, false nullable prose, and blank-output fallback are removed.
+- **Important rejected concerns:** No container/scoped binding, static factory, worker cache, registry, lock, `CoroutineContext`, configurable limits, graph engine, validator, keyword table, raw assertion bag, `NullType`, serializer cycle tracker, schema-valued additional-properties bridge, recursive object-default reinterpretation, or default-versus-branch validation was added. These mechanisms had no supported consumer or would duplicate a JSON Schema validator.
+- **Regression coverage:** Tests cover the current factory and contract surface; reversible flags; sum types and defaults; primitive, object, array, nullable, composition, and reference reconstruction; active-path and aggregate expansion limits; exact property/default JSON shapes; malformed recognized keywords; unsupported assertions; integer range boundaries; falsey and permissive forms; explicit null defaults; direct union-member failures; and JSON encoding exceptions. Counterfactual rows pin every corrected silent-loss and warning/error path.
+- **Performance and compatibility:** Ordinary builder use adds only bounded local checks and object-shape classification. `fromArray()` is explicit cold work; its fixed keyword checks and counters replace recursive reference copying with a lower-memory loop and hard bounds. No request path gains I/O, synchronization, yielding, retries, container resolution, or retained worker state. Current Laravel-facing APIs and named arguments are preserved; concrete null defaults and sum-type defaults are additive approved widenings, and invalid inputs now fail instead of producing a weaker or unusable schema.
+- **Upstream handoff:** Current Laravel 13.x retains direct union-member coercion, unbounded recursive reference following, lossy reconstruction of scalar/constrained null and unsupported or malformed assertions, nested-composition loss, boolean `oneOf` loss, over-rejection of representable `items: true`, numeric object-map encoding, blank-string JSON failures, and invalid finally-empty compositions. Each can be upstreamed independently without Hypervel lifecycle machinery.
+- **Validation and review:** Changed tests passed throughout implementation; the complete JSON Schema suite, formatting, both PHPStan configurations, the full parallel components suite, Testbench package mode, dogfood, stale-marker scans, and `git diff --check` are green. Post-review corrections passed the complete package tests and both normal PHPStan configurations. Independent review reproduced the defect paths, rechecked neighboring behavior and performance, and signed off after every correction.
+- **Assessment:** JSON Schema is current at the audited Laravel surface, coroutine-safe by operation-local construction, bounded against worker-fatal reference expansion, and truthful at every supported reconstruction and serialization boundary. Every accepted finding is resolved without a workaround, speculative mechanism, useful Laravel API removal, meaningful hot-path regression, stale code, unresolved defect, or deferred TODO.
From d4585210a827d1c517e6a70bf4803c0468cf275d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:04:25 +0000
Subject: [PATCH 7/9] Document the JSON Schema audit implementation
Capture the verified Laravel parity surface, serializer and reconstruction defects, approved API improvements, bounded-reference design, and explicit anti-overengineering constraints.
Describe the final implementation by owning boundary, include representative code, define the regression and full-gate validation plan, and preserve the completed audit decisions needed for future maintenance.
---
...rrent-parity-and-bounded-reconstruction.md | 447 ++++++++++++++++++
1 file changed, 447 insertions(+)
create mode 100644 docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md
diff --git a/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md b/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md
new file mode 100644
index 000000000..c7acb4750
--- /dev/null
+++ b/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md
@@ -0,0 +1,447 @@
+# JSON Schema Correctness, Current Parity, and Bounded Reconstruction
+
+## Scope and outcome
+
+Bring `hypervel/json-schema` to the current Laravel 13.x API while fixing the verified serializer and deserializer defects in both implementations. The final package must:
+
+- retain its operation-local, allocation-only design with no container, coroutine, cache, lock, or worker-reset machinery;
+- expose current `unique`, reversible flags, `fromArray`, union, and any-of APIs;
+- reconstruct only schemas the builder can preserve, throwing instead of silently weakening recognized assertions;
+- bound aggregate reference expansion and active reference paths without recursive reference-frame copying;
+- emit correct JSON object shapes for numeric property names and object defaults;
+- preserve valid empty enums and scalar or array-form null-only schemas while rejecting malformed or finally-empty compositions;
+- preserve explicit null defaults without confusing them with an unset default;
+- fail with the original JSON encoding exception rather than returning a blank schema;
+- document the package, its supported reconstruction subset, and its deliberate Laravel-facing differences in Laravel-docs prose.
+
+References verified for this design:
+
+- Hypervel `de04fad613a8158750d6f14af5677c03587f5170`: the complete JSON Schema package, contract, tests, Composer metadata, documentation index, and repository callers;
+- Laravel framework `deac04fbdcd7443aff45a7bc6767a6729169bba3`: current `Illuminate\JsonSchema` source and tests;
+- originating Laravel changes #58922, #59688, #60149, #60239, #60384, #60455, #60509, #60517, and #60524;
+- JSON Schema 2020-12 validation metaschema and Opis behavior for property maps, compositions, defaults, and enums;
+- focused reference-chain, lossy-reconstruction, wire-shape, and encoding-failure probes.
+
+The current package has no provider, binding, configuration, callback, resource, external service, mutable static runtime state, or runtime consumer. `JsonSchema::__callStatic()` creates a fresh factory, every factory method creates a fresh builder, and deserialization remains per call. That lifetime is already coroutine-safe and must remain unchanged.
+
+## What this audit is not
+
+The following wording is retained verbatim from the core audit plan. Its principle numbering is also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this plan” refers to that plan's **Established remediation vocabulary** section.
+
+This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust.
+
+Complexity must pay for itself with at least one of:
+
+- a demonstrated failure;
+- a complete source trace proving a realistic vulnerable schedule;
+- a clear general capability with real consumers and owner approval;
+- deletion of greater or riskier complexity elsewhere.
+
+Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass.
+
+Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities.
+
+Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole.
+
+The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work.
+
+### 7. Preserve hot-path quality
+
+For every fix, inspect:
+
+- additional allocations;
+- container or facade resolutions;
+- locking and atomics;
+- hashing and serialization;
+- new yields or sleeps;
+- retries and polling;
+- logging or exception construction;
+- retained worker memory;
+- cache invalidation and eviction.
+
+A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly.
+
+Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim.
+
+Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding.
+
+### 8. Remove superseded design completely
+
+When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect.
+
+### 9. Treat remediation patterns as candidates
+
+The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner.
+
+### 10. Reject speculative complexity
+
+Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them.
+
+## Findings and final decisions
+
+| ID | Category / severity | Final decision |
+|---|---|---|
+| `json-schema-01` | Parity defect / Major | Port current Laravel `unique`, reversible flags, `fromArray`, union, and any-of surfaces with Hypervel typing. |
+| `json-schema-02` | Upstream availability defect / Critical (High confidence) | Replace recursive `$ref` following with a local loop; count each build and ref follow; cap one active root-to-node path at 256 distinct refs. |
+| `json-schema-03` | Upstream reconstruction defect / Major | Reject recognized malformed or unrepresentable assertions rather than returning a weaker schema; keep unknown annotations ignored. |
+| `json-schema-04` | Upstream wire-shape defect / Minor | Encode list-shaped property maps and top-level object defaults as JSON objects, with exact round-trip normalization. |
+| `json-schema-05` | Upstream diagnostic defect / Minor | Use `JSON_THROW_ON_ERROR` so invalid values raise `JsonException` instead of producing `''`. |
+| `json-schema-06` | Upstream composition defect / Minor | Reject only finally-empty union/any-of output and empty input `type` arrays; reconstruct null-only type arrays before inference. |
+| `json-schema-07` | Documentation improvement | Add package provenance, documentation link, Boost guide, and navigation. |
+| `json-schema-08` | Documentation defect / Minor | Describe `nullable()` as permitting null, not making a property optional. |
+| `json-schema-09` | Approved API improvement | Widen each concrete default setter with `null` while preserving its existing non-null domain. |
+| `json-schema-10` | Approved API improvement | Add fluent `default(mixed)` to union and any-of without validating annotation values against branches. |
+| `json-schema-11` | Upstream reconstruction defect / Major | Reject unsupported JSON Schema 2020-12 assertions instead of silently weakening them; only a bare null branch may collapse into nullability. |
+| `json-schema-12` | Upstream reconstruction defect / Major | Reject recognized keyword values that would be coerced, dropped, or deferred; preserve permissive `items: true`; reject surviving nested compositions and empty input compositions. |
+| `json-schema-13` | Upstream builder defect / Major | Reject non-string direct union members instead of coercing them, warning, or raising an undocumented PHP `Error`. |
+
+No accepted item adds request hot-path work. Builder serialization gains constant-time state checks and one property/default shape test. Deserialization work occurs only when `fromArray()` is explicitly called and is bounded more tightly than upstream.
+
+## Implementation
+
+### 1. Port the complete current Laravel surface
+
+Port the current source and tests rather than historical snapshots:
+
+- add `Deserializer`, `Types\UnionType`, and `Types\AnyOfType`;
+- add `JsonSchema::fromArray()` and magic annotations;
+- add `union()` and `anyOf()` to `JsonSchemaTypeFactory` and `Hypervel\Contracts\JsonSchema\JsonSchema`, with native concrete returns;
+- add `ArrayType::$uniqueItems` and reversible `unique(bool $unique = true)`;
+- make `required(false)` and `nullable(false)` clear their serialized flags;
+- stringify numeric required property names;
+- preserve Hypervel's truthful `array $arguments` on `__callStatic()`, strict types, native returns, exact-class serializer dispatch, and existing tests;
+- validate direct union members in one pass before null normalization or supported-name checks; never coerce them with `strval`.
+
+Representative factory surface:
+
+```php
+public function union(array $types): Types\UnionType
+{
+ return new Types\UnionType($types);
+}
+
+public function anyOf(Closure|array $schemas): Types\AnyOfType
+{
+ if ($schemas instanceof Closure) {
+ $schemas = $schemas($this);
+ }
+
+ return new Types\AnyOfType($schemas);
+}
+```
+
+`fromArray()` remains a concrete static reconstruction helper rather than a factory-contract method:
+
+```php
+public static function fromArray(array $schema): Type
+{
+ return Deserializer::deserialize($schema);
+}
+```
+
+### 2. Model explicit defaults once
+
+Separate the value from whether it was supplied:
+
+```php
+protected mixed $default = null;
+
+protected bool $hasDefault = false;
+
+protected function setDefault(mixed $value): static
+{
+ $this->default = $value;
+ $this->hasDefault = true;
+
+ return $this;
+}
+```
+
+Each concrete setter retains its domain and adds only null, for example:
+
+```php
+public function default(string|null $value): static
+{
+ return $this->setDefault($value);
+}
+```
+
+Use `array|null`, `bool|null`, `int|null`, `int|float|null`, and `string|null` on the existing six types.
+
+`default(mixed $value): static` on `UnionType` and `AnyOfType` is an approved Laravel API widening. Both methods delegate to `setDefault()` because a sum schema can admit any JSON value. Do not validate defaults against member types or branches: JSON Schema treats defaults as annotations, and doing so would require a validator.
+
+The deserializer must assign through the same helper so the value and flag cannot diverge:
+
+```php
+(fn (mixed $value) => $this->setDefault($value))->call($type, $default);
+```
+
+Use one serializer filter for both AnyOf's early path and ordinary types. It removes internal fields and retains `default => null` only when `$hasDefault` is true:
+
+```php
+protected static function filterAttributes(array $attributes): array
+{
+ $hasDefault = $attributes['hasDefault'];
+
+ return array_filter($attributes, static function (mixed $value, string $key) use ($hasDefault): bool {
+ if (in_array($key, static::$ignore, true)) {
+ return false;
+ }
+
+ return $value !== null || ($key === 'default' && $hasDefault);
+ }, ARRAY_FILTER_USE_BOTH);
+}
+```
+
+Include `hasDefault` in the ignored internal fields. Do not add a sentinel object or widen the existing concrete setters to `mixed`.
+
+### 3. Bound reference work and remove recursive copying
+
+`Deserializer` remains one per call, with promoted root state, its existing target cache, and an active reference list passed by value between schema branches. Add:
+
+```php
+protected const MAX_NODES = 20000;
+
+protected const MAX_REFERENCE_DEPTH = 256;
+
+protected function countNode(): void
+{
+ if (++$this->nodes > static::MAX_NODES) {
+ throw new InvalidArgumentException(/* existing expansion message */);
+ }
+}
+```
+
+Call `countNode()` once at each `build()` and once immediately before every actual `$ref` follow. `buildAnyOfComposition()` and `normalizeUnions()` resolve branches before `build()`, so ref accounting belongs inside `resolveRef()`, not only at build entry.
+
+Replace the tail recursion with a local loop:
+
+```php
+while (array_key_exists('$ref', $schema)) {
+ if (! is_string($schema['$ref'])) {
+ throw new InvalidArgumentException('The JSON Schema [$ref] keyword must be a string.');
+ }
+
+ $ref = $schema['$ref'];
+
+ if (in_array($ref, $refs, true)) {
+ throw new InvalidArgumentException("Circular JSON Schema \$ref [{$ref}] detected.");
+ }
+
+ if (count($refs) >= static::MAX_REFERENCE_DEPTH) {
+ throw new InvalidArgumentException(/* active-path depth message */);
+ }
+
+ $this->countNode();
+ $refs[] = $ref;
+
+ $resolved = $this->lookupRef($ref);
+ unset($schema['$ref']);
+ $schema = array_merge($resolved, $schema);
+}
+```
+
+The depth cap means at most 256 distinct references on one active root-to-node reference path. It is not a general schema-nesting limit. The total node budget separately bounds aggregate work across wide schemas and repeated sibling refs. The loop eliminates recursive stack frames and quadratic path copying; the two guards remain necessary because they constrain different axes.
+
+Preserve circular-reference diagnostics and the per-call lookup cache. Do not add a shared registry, graph engine, config knob, or worker cache. A memory-limit fatal is not catchable and would kill the Swoole worker and its concurrent requests, so both guards are correctness boundaries rather than speculative hardening. The recursive implementation expanded about 130 KiB of chained input to 192 MiB and died near 200 KiB at a 256 MiB limit; the iterative loop handled 6,000 links in about 8 MiB and 79 ms.
+
+### 4. Make reconstruction truthful
+
+Port Laravel's supported-subset deserializer, then close every recognized-loss path at that boundary.
+
+#### Recognized keyword values
+
+At each existing keyword read, distinguish absence with `array_key_exists()` and require the PHP type that can be preserved without coercion. Validate only representation: do not add range checks, regex compilation, cross-keyword validation, or a keyword-type table.
+
+- `$ref`, `type`, `title`, `description`, `pattern`, and `format` must have their expected string/array shape; `type` arrays contain strings.
+- `anyOf` and `oneOf` must be non-empty arrays, and every branch must be a schema array.
+- `properties` accepts `array|stdClass`; `required` must be an array of strings.
+- integer-valued count/length constraints pass through `toNumber()` and a range-safe `toInteger()`; numeric strings remain accepted, while fractional, nonnumeric, and out-of-range values throw.
+- numeric constraints use presence checks and retain their existing number normalization.
+- `uniqueItems` must be boolean.
+- `items: true` and `items: []` are representable permissive forms; `false`, tuple/list schemas, and malformed values throw.
+
+Values such as negative lengths, zero `multipleOf`, or invalid regex syntax remain unchanged because they are preserved exactly; evaluating them would turn the deserializer into a partial validator.
+
+Type inference uses keyword-presence checks so null-valued recognized keywords reach their owning guard instead of disappearing. Out-of-range integer constraints receive a distinct, non-lossy diagnostic.
+
+#### Object properties and required names
+
+Accept property maps emitted by this serializer as arrays or `stdClass`, normalize them once, and build each property normally. Require string-valued required names; keep property-key normalization because PHP converts numeric property names to integer keys. Reject any required name absent from the normalized property keys. Do not silently turn a required-only object into an unconstrained object.
+
+#### `additionalProperties`
+
+Use `array_key_exists('additionalProperties', $schema)` so absence remains distinct from an explicitly invalid null value:
+
+- absent, `true`, and `[]` mean the representable permissive default;
+- `false` calls `withoutAdditionalProperties()`;
+- a non-empty schema array, any object, `null`, or another scalar throws because the builder cannot preserve it.
+
+Do not add schema-valued additional-property support or retain raw keywords beside the type model.
+
+#### General `anyOf`
+
+Extract the existing recognized type-specific keyword list to one protected constant:
+
+```php
+protected const TYPE_SPECIFIC_KEYWORDS = [
+ 'minLength', 'maxLength', 'pattern', 'format',
+ 'minimum', 'maximum', 'multipleOf',
+ 'items', 'minItems', 'maxItems', 'uniqueItems',
+ 'properties', 'required', 'additionalProperties',
+];
+```
+
+The multi-type union guard uses this constant. In `buildAnyOfComposition()`, first identify and remove null branches. Preserve the existing nullable-single-schema path by returning `null` before applying the general-composition guard; `normalizeUnions()` owns those collapsed forms and must continue accepting their type-specific siblings. Only the path that will construct a real `AnyOfType` rejects the constant's keys plus `type` and `oneOf`. After nullable collapse, reject any `anyOf` or `oneOf` keyword still present in the merged fragment so siblings, inline branches, and resolved references cannot silently replace one another. Continue preserving `title`, `description`, `enum`, `default`, and nullability. Continue ignoring unknown annotations such as `$schema`, `$comment`, `readOnly`, and `contentEncoding`; this package is not a general validator.
+
+#### Enum shape
+
+Use `array_key_exists('enum', $schema)` and require the present value to be an array. This rejects malformed strings, objects, scalars, and null instead of dropping them. Preserve `enum([])`: JSON Schema 2020-12 intentionally permits an empty enum as an unsatisfiable schema, even though Opis imposes a stricter non-empty rule. Preserve enum order, duplicates, and complex values without recursive validation.
+
+#### Unsupported 2020-12 assertions
+
+Reject unsupported standard assertions before they can be dropped. Keep one protected constant containing `const`, `not`, `allOf`, `if`, `dependentSchemas`, `dependentRequired`, `prefixItems`, `contains`, `patternProperties`, `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `exclusiveMinimum`, `exclusiveMaximum`, `minProperties`, `maxProperties`, and `$dynamicRef`. Guard the merged fragment once after `resolveRef()` and again after `normalizeUnions()`, whose collapsed branch can introduce new keys.
+
+The presence-based guard is deliberately conservative: value-level no-ops such as a bare `if` or empty `patternProperties` also throw. Do not add keyword-specific evaluation or a general validator. Standalone `then`, `else`, `minContains`, and `maxContains` remain ignored because they have no effect without the guarded owning assertion. Standard annotations and vendor extensions remain ignored.
+
+Only a branch whose sole key is `type` with value `"null"` or `["null"]` may collapse into outer nullability. A non-bare null branch in `anyOf` remains a real branch so supported assertions and annotations are preserved; the equivalent general `oneOf` is rejected because there is no `OneOfType`. The strict classifier and assertion guard must land together: otherwise a constrained null branch is discarded before the guard can see it. This intentionally rejects type-specific keywords on a null branch, even though those keywords are no-ops for null, for consistency with the existing null-only union constraint boundary.
+
+### 5. Preserve valid composition semantics
+
+An empty union or any-of builder is a legitimate intermediate state before `nullable()` adds the null member. For `UnionType`, resolve and append nullability first, then validate the final serialized member list:
+
+```php
+if ($attributes['type'] === []) {
+ throw new InvalidArgumentException('A JSON Schema union must contain at least one type.');
+}
+```
+
+For `AnyOfType`, serialize the branches, append its null branch when nullable, and only then apply the equivalent check. Add the composition exception to `toArray()`, `toString()`, and `__toString()` documentation.
+
+In `resolveType()`:
+
+- reject an explicitly present non-string/non-array `type` before inference;
+- reject `type: []` explicitly before inference;
+- detect scalar `"type": "null"` and null-only type arrays before inference and return an empty `UnionType` member list with nullability enabled;
+- return the null-only result as an array so `build()`'s existing pre-construction `ensureUnionConstraintsAreSupported()` call also covers it; this makes `type: ["null"]` plus a recognized type-specific assertion throw rather than fabricate another type;
+- preserve bare scalar and array-form null-only schemas, `union(['null'])`, `union([])->nullable()`, `anyOf([])->nullable()`, and input null-only AnyOf.
+
+Change the deserializer's constraint diagnostic to say that type-specific keywords are unsupported on a JSON Schema union, since it also covers null-only forms. Keep `UnionType`'s unsupported-member diagnostic unchanged. The union diagnostic remains slightly abstract for scalar null input; do not add a special branch for that pathological error case. Require a “bare null branch” in the nullable `oneOf` diagnostic.
+
+Do not add a `NullType`; the existing union model represents the valid result without another public type.
+
+### 6. Emit JSON object shapes at the known boundaries
+
+PHP coerces numeric-string keys, so a property map containing `"0"` or sequential `"0"`/`"1"` keys becomes list-shaped and would encode as a JSON array. After serializing object properties:
+
+```php
+$properties = array_map(
+ static fn (Types\Type $property) => static::serialize($property),
+ $attributes['properties'],
+);
+
+$attributes['properties'] = array_is_list($properties)
+ ? (object) $properties
+ : $properties;
+```
+
+Apply the same top-level shape rule to a provided `ObjectType` default only when `is_array($attributes['default'])`. A list-shaped array becomes `stdClass`; an associative map remains an array; explicit null remains null. The attribute filter runs first, so an unset default is already absent. Keep this conversion outside the properties-count branch so `object()->default([])` is corrected even when the object has no declared properties. During reconstruction, normalize only serializer-emitted `stdClass` property maps and `ObjectType` defaults back to PHP arrays.
+
+Do not recursively reinterpret nested arrays inside object defaults. The `array` signature establishes only the top-level object shape; nested values do not carry enough information to distinguish JSON arrays from objects.
+
+### 7. Preserve JSON encoding failures
+
+Replace the false-to-empty-string fallback:
+
+```php
+return json_encode(
+ $this->toArray(),
+ JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
+);
+```
+
+Document `JsonException` on `toString()` and `__toString()`. Do not pre-scan default or enum values; the JSON encoder is the authoritative boundary and already reports malformed UTF-8, non-finite numbers, resources, and other unencodable values.
+
+### 8. Complete documentation and provenance
+
+Update `src/json-schema/README.md` in repository order:
+
+1. package title;
+2. `Documentation: https://hypervel.org/docs/json-schema`;
+3. a concise `Differences From Laravel` section;
+4. `Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/JsonSchema`.
+
+Group the public differences, rather than writing a changelog:
+
+- Hypervel accepts explicit null defaults and provides fluent defaults on union and any-of schemas;
+- invalid JSON values raise `JsonException`, and finally-empty compositions throw instead of producing unusable output;
+- `fromArray()` rejects malformed or unsupported 2020-12 assertions it cannot preserve, accepts scalar and array-form null-only schemas and permissive `items: true`, and bounds active-reference depth as well as total expansion.
+
+Add `src/boost/docs/json-schema.md` in Laravel-docs prose and link it under **Digging Deeper**, between HTTP Client and Localization. Cover practical primitive, object, and array construction; `required()` versus `nullable()`; metadata and constraints; `unique()`, `union()`, and `anyOf()`; array/string output; `fromArray()`; local references; and the supported-subset failure boundary. State only user-actionable behavior, not serializer/deserializer internals.
+
+Document the backed-enum form of `enum()` and distinguish `JsonException` encoding failures from the `InvalidArgumentException` raised by finally-empty compositions in both array and string output.
+
+Correct the `nullable()` title to say that the type may be null. Omission remains controlled by `required()`.
+
+### 9. Keep types and tests consistent
+
+- promote `Deserializer::$root` through its protected constructor;
+- add native `Types\IntegerType|Types\NumberType` to `applyNumericBounds()` while retaining its generic docblock;
+- add `: void` to every JSON Schema test method;
+- preserve all existing Hypervel tests while porting current Laravel `AnyOfTypeTest`, `DeserializerTest`, and `UnionTypeTest` plus current additions to existing test classes.
+
+No package base test case, integration service, coroutine interleaving, subprocess, or static cleanup hook is warranted.
+
+## Regression coverage
+
+Run each changed or new test file immediately. The final package suite must cover:
+
+1. current factory/contract/magic APIs, `unique()`, numeric required-name normalization, and all reversible flags;
+2. union and any-of construction, closures, metadata, nullable forms, final-empty rejection, and fluent defaults;
+3. `fromArray()` for every primitive/object/array/sum type, numeric bounds, local refs, escaped pointer segments, nullable unions, and round trips; pin a multi-link `$ref` chain where outer siblings override intermediate/target values while non-conflicting keys accumulate from every level;
+4. direct and nested active-ref depth rejection, refs consuming the total budget, repeated sibling refs remaining valid, and unchanged circular-reference errors using low-limit test subclasses;
+5. missing or malformed required/properties values; schema-valued/invalid `additionalProperties`; absent, `true`, and `[]` permissive `additionalProperties`; recognized assertion siblings rejected only on real general AnyOf while nullable single-schema AnyOf/OneOf siblings remain supported; malformed non-array/null enums; and ignored unknown annotations;
+6. `type: []` rejection; scalar and array-form null-only reconstruction at roots, properties, items, and refs; null-only annotations; strict bare-null branch classification; and null-only type-specific assertion rejection;
+7. numeric `"0"`, sequential numeric, and non-list property names producing the right PHP/JSON shapes and validating through Opis;
+8. empty and numeric-list top-level object defaults, exact array/object distinction, and builder-to-array reconstruction;
+9. explicit null defaults and unset-default omission for all six concrete types plus union and any-of;
+10. invalid UTF-8, non-finite numbers, and resources raising `JsonException` at string conversion;
+11. direct and reconstructed `enum([])` preservation without Opis validation, with one short comment explaining that Opis's non-empty rule is stricter than JSON Schema 2020-12.
+12. every unsupported 2020-12 assertion keyword rejected, while standalone `then`, `else`, `minContains`, `maxContains`, standard annotations, and vendor extensions remain accepted.
+13. PHP integer endpoints preserved; out-of-range integral floats, fractional/nonnumeric count and length values, and malformed recognized keyword types rejected while numeric strings remain accepted.
+14. permissive `items: true` and `items: []`; rejected `items: false`; eager empty-input composition rejection; boolean `oneOf` branch rejection; and every sibling/branch/ref path that could leave a second composition after nullable collapse.
+15. explicit null `type` and direct or reference-revealed non-string `$ref` values rejected at their owning boundaries rather than inferred or dropped.
+16. direct union construction rejects every non-string member without warnings or PHP errors; null-valued inferred keywords reach their owning guards; range failures have an exact diagnostic; and unsupported assertions or surviving same-keyword compositions are rejected inside composition branches.
+
+After focused files pass, run the complete `tests/JsonSchema` suite, targeted source PHPStan if needed, then `composer fix` once at the implementation checkpoint.
+
+## Performance and lifecycle result
+
+- Existing builder creation remains fresh and operation-local; no worker state or cleanup is introduced.
+- Existing builder calls add no container lookup, lock, yield, I/O, or retained cache.
+- Serialization adds only constant-time flag checks and `array_is_list()` at object-shape boundaries.
+- `fromArray()` adds small local type/presence guards, a fixed-size assertion intersection before building and after an actual nullable-branch merge, and one counter per build/ref follow while replacing recursive reference copies with a lower-memory loop.
+- The 256 active-path bound and aggregate node budget prevent unbounded CPU/memory amplification; no valid ordinary schema should approach either limit.
+
+## Rejected designs
+
+- No cached static factory, container/scoped binding, `CoroutineContext`, static reset, lock, registry, or worker cache: no shared mutable state exists.
+- No general or value-aware schema validator, keyword-type table, semantic range/regex validation, recursive default/enum scan, raw keyword bag, schema-valued `additionalProperties`, or default-vs-branch validation: each would duplicate a validator or create competing state.
+- No serializer cycle registry: a direct self-cycle is developer-created, fails during development, and cannot be constructed through `fromArray()`, whose circular refs already throw.
+- No subtype dispatch redesign: exact-class serialization truthfully rejects unknown types and there is no documented subtype contract.
+- No `NullType`: null-only output is representable through the existing union/any-of nullability model.
+- No recursive object-default shape conversion: only the top-level object intent is known.
+- No rejection or Opis validation of empty enums: the 2020-12 contract permits them.
+- No runtime dependency change: Opis remains test-only.
+
+## Records and completion
+
+After implementation, validation, self-review, and code-review sign-off:
+
+- add the final JSON Schema work unit to the companion audit ledger, including the upstream-shared defects, worker-fatal reference risk, accepted API widenings, and rejected machinery;
+- set the core audit routing index to the active/completed JSON Schema work unit, add only genuine cross-package dependency rows if implementation discovers one, and check `json-schema` complete;
+- record the Laravel-facing result: current APIs restored; explicit-null and sum-type defaults intentionally widened; malformed or unsupported lossy reconstruction and final-invalid output rejected; no useful Laravel API removed;
+- name the unchanged Laravel 13.x defects in the owner summary so the owner can decide whether to upstream them: direct union-member coercion, unbounded recursive reference following, lossy reconstruction (including scalar null, constrained null branches, unsupported 2020-12 assertions, coerced/dropped keyword values, nested composition loss, and boolean `oneOf` branches), over-rejection of representable `items: true`, numeric object-map encoding, blank-string JSON failures, and invalid empty compositions;
+- leave no TODO or deferred accepted finding.
+
+Then provide the owner with the complete pre-commit summary required by the core audit workflow and wait for explicit approval. Do not create any source, test, documentation, ledger, or bookkeeping commit before that approval.
From aff979afd26816d69c439571c267a80d7159ba49 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:16:08 +0000
Subject: [PATCH 8/9] Refine the JSON Schema guide
Rewrite the guide in the simple, direct prose used by first-party Laravel documentation while preserving the original baseline in commit history.
Clarify reversible constraints, supported union members, serialization failures, reference limits, nullable one-of reconstruction, permissive array forms, malformed inputs, unsupported keywords, and valid schema forms the fluent builder cannot preserve.
---
src/boost/docs/json-schema.md | 43 ++++++++++++++++++++++++-----------
1 file changed, 30 insertions(+), 13 deletions(-)
diff --git a/src/boost/docs/json-schema.md b/src/boost/docs/json-schema.md
index 21e24c5f0..c83f1fdba 100644
--- a/src/boost/docs/json-schema.md
+++ b/src/boost/docs/json-schema.md
@@ -28,7 +28,7 @@ $schema = JsonSchema::object([
]);
```
-Each builder is a fresh, independent object, so schemas may be safely constructed for individual requests or operations.
+Each call creates a new schema builder, so you may build schemas independently without sharing state between them.
## Building Schemas
@@ -60,7 +60,7 @@ $schema = JsonSchema::object([
]);
```
-A closure may be used when you prefer to build properties from the provided factory:
+If you prefer to use the provided schema factory, you may pass a closure to the `object` method:
```php
use Hypervel\JsonSchema\JsonSchemaTypeFactory;
@@ -99,6 +99,8 @@ $schema = JsonSchema::array()
->unique();
```
+You may pass `false` to the `unique` method to remove the unique-items constraint.
+
### Metadata and Constraints
@@ -120,12 +122,12 @@ $schema = JsonSchema::string()->enum(Status::class);
String schemas also provide `min`, `max`, `pattern`, and `format`. Integer and number schemas provide `min`, `max`, and `multipleOf`. Array schemas provide `min`, `max`, `items`, and `unique`.
-Defaults are annotations and are not validated against the schema. An explicit `null` default is preserved even when the schema itself is not nullable.
+The `default` method adds the JSON Schema `default` annotation. Default values are not validated against the schema, and an explicit `null` default is preserved even when the schema is not nullable.
### Required and Nullable Properties
-The `required` and `nullable` methods control different behavior. Calling `required` means an object property must be present. Calling `nullable` means its value may be `null`:
+Although they are often used together, the `required` and `nullable` methods control different behavior. The `required` method indicates that an object property must be present, while the `nullable` method indicates that its value may be `null`:
```php
$schema = JsonSchema::object([
@@ -139,12 +141,14 @@ In this example, `name` must be present. The optional `nickname` property may be
### Union Types
-The `union` method accepts JSON Schema primitive type names and allows a value to match any of them:
+The `union` method accepts an array of JSON Schema type names and allows a value to match any of them. Supported types are `string`, `integer`, `number`, `boolean`, `object`, and `array`:
```php
$schema = JsonSchema::union(['string', 'integer']);
```
+You may also include `null` as a union member, which has the same effect as calling the `nullable` method.
+
Union schemas may also be nullable or carry shared metadata:
```php
@@ -185,12 +189,14 @@ $json = $schema->toString();
$json = (string) $schema;
```
-String conversion throws a `JsonException` when a default, enum value, or other schema value cannot be encoded as JSON. Calling `toArray` or converting to JSON throws an `InvalidArgumentException` for an empty union or any-of builder unless `nullable` adds a valid `null` alternative.
+If a default, enum value, or other schema value cannot be encoded as JSON, the `toString` method and string casting will throw a `JsonException`.
+
+An empty union or any-of schema cannot be serialized unless the `nullable` method adds `null` as a valid alternative. Otherwise, the `toArray` and `toString` methods, as well as string casting, will throw an `InvalidArgumentException`.
## Reconstructing Schemas
-The `fromArray` method reconstructs a builder from a supported JSON Schema array:
+If you already have a JSON Schema represented as a PHP array, you may use the `fromArray` method to create a schema builder from it:
```php
$schema = JsonSchema::fromArray([
@@ -202,12 +208,12 @@ $schema = JsonSchema::fromArray([
]);
```
-This is useful when a schema is stored as configuration or received from another trusted source and you need to extend it or serialize it through the builder.
+This can be useful when loading a schema from configuration or another system and you would like to continue working with it through the fluent builder.
### Local References
-`fromArray` resolves local JSON Pointer references, including references into `$defs`:
+The `fromArray` method also resolves local JSON Pointer references, including references into `$defs`:
```php
$schema = JsonSchema::fromArray([
@@ -226,13 +232,24 @@ $schema = JsonSchema::fromArray([
]);
```
-Remote references are not supported. Circular references, excessive reference depth, and excessive total expansion throw an `InvalidArgumentException`.
+Only local references are supported. An `InvalidArgumentException` will be thrown if the schema contains a remote or circular reference, a reference path with more than 256 references, or more than 20,000 expanded schema fragments.
### Supported Schema Subset
-The builder reconstructs the primitive, object, array, union, any-of, nullable, metadata, enum, default, and constraint keywords exposed by its fluent API. Null-only schemas are accepted using either the scalar or array form. A permissive `items: true` behaves like an omitted item constraint. Standard annotations and vendor extensions that are not modeled by the builder are ignored.
+The `fromArray` method can reconstruct schemas that use the same types, metadata, and constraints available through the fluent builder. It also accepts schemas whose only allowed type is `null`, whether the type is written as a string or as an array.
+
+When working with schemas represented as PHP arrays, setting `items` to `true` or `[]` is treated the same as omitting the item constraint. Likewise, setting `additionalProperties` to `true` or `[]` preserves the default behavior of allowing additional properties.
+
+A `oneOf` schema may only be reconstructed when it contains one schema and a branch whose only keyword is `"type": "null"`. In this case, the schema is reconstructed as nullable.
+
+Annotations and vendor extensions that are not represented by the builder are ignored.
+
+> [!WARNING]
+> The `fromArray` method will throw an `InvalidArgumentException` when a schema contains a recognized JSON Schema 2020-12 validation rule that cannot be represented by the fluent builder. This prevents the rule from being silently discarded.
+
+The unsupported JSON Schema 2020-12 validation keywords are `const`, `not`, `allOf`, `if`, `dependentSchemas`, `dependentRequired`, `prefixItems`, `contains`, `patternProperties`, `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `exclusiveMinimum`, `exclusiveMaximum`, `minProperties`, `maxProperties`, and `$dynamicRef`.
-Unsupported JSON Schema 2020-12 assertions are rejected instead of being silently removed. These include `const`, `not`, `allOf`, `if`, `dependentSchemas`, `dependentRequired`, `prefixItems`, `contains`, `patternProperties`, `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `exclusiveMinimum`, `exclusiveMaximum`, `minProperties`, `maxProperties`, and `$dynamicRef`.
+An `InvalidArgumentException` will also be thrown when a supported keyword contains a malformed value, such as a non-string `pattern`, a non-array `required` value, or an empty `anyOf` or `oneOf` array.
-An `InvalidArgumentException` is also thrown for malformed recognized keywords, empty input compositions, schema-valued `additionalProperties`, tuple or false `items`, boolean property schemas, type-specific assertions on unions, and competing compositions. This prevents a reconstructed builder from silently accepting data that the original schema rejected.
+Some valid JSON Schema forms cannot be represented by the fluent builder. These include an `additionalProperties` value that contains another schema, tuple or `false` values for `items`, boolean schemas used as object properties or composition branches, type-specific constraints on a multi-type union, and `anyOf` or `oneOf` schemas that carry incompatible type or composition rules. A nullable composition also cannot be reconstructed when its non-null branch and the keywords beside the composition give different values for the same keyword. Attempting to reconstruct these forms will throw an `InvalidArgumentException`.
From bf78c85805127e41b3444436ac9cad94ff07b1ec Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 8 Aug 2026 14:09:10 +0000
Subject: [PATCH 9/9] fix(json-schema): preserve nullable composition semantics
Reject nullable oneOf collapses when more than one branch can match null, including duplicate and reference-resolved null branches. Keep branch-local enums scoped inside nullable anyOf compositions and reject the corresponding oneOf form when flattening would change its meaning.
Add counterfactual coverage for exact oneOf cardinality, deliberate anyOf overlap, enum ownership, references, structural siblings, annotations, and existing conflict diagnostics. Clarify the supported reconstruction boundary in the canonical guide and keep the package README limited to genuine additive API differences.
Record the two upstream reconstruction defects under their durable audit findings. The checks remain bounded to explicit fromArray calls and add no shared state or request hot-path work.
---
...-coroutine-state-lifecycle-audit-ledger.md | 9 +-
...rrent-parity-and-bounded-reconstruction.md | 18 +-
src/boost/docs/json-schema.md | 6 +-
src/json-schema/README.md | 2 +-
src/json-schema/src/Deserializer.php | 65 +++-
tests/JsonSchema/DeserializerTest.php | 285 ++++++++++++++++++
6 files changed, 365 insertions(+), 20 deletions(-)
diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
index c49163ea1..a5a13d02d 100644
--- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -2024,21 +2024,22 @@ Append package entries in checklist order. Keep each entry compact but complete
|---|---|
| `json-schema-01` | Port current Laravel `unique`, reversible flags, `fromArray`, union, and any-of APIs with truthful Hypervel typing. |
| `json-schema-02` | Replace recursive reference following with a local loop, cap one active path at 256 distinct references, and bound aggregate expansion without shared state. |
-| `json-schema-03`, `json-schema-11`, `json-schema-12` | Reject malformed, unsupported, coerced, dropped, deferred, or structurally lossy reconstruction; preserve representable permissive forms, empty enums, and bare null schemas. |
+| `json-schema-03`, `json-schema-11`, `json-schema-12` | Reject malformed, unsupported, coerced, dropped, deferred, or structurally lossy reconstruction; preserve representable permissive forms and empty enums; reconstruct bare null schemas while collapsing only bare null branches and preserving `oneOf` exact-match cardinality. |
| `json-schema-04` | Emit numeric/list-shaped property maps and top-level object defaults as JSON objects and normalize those exact boundaries during reconstruction. |
| `json-schema-05` | Preserve JSON encoding failures with `JSON_THROW_ON_ERROR` instead of returning a blank schema. |
| `json-schema-06` | Validate union and any-of only after nullability is applied, reject finally-empty output and empty input type arrays, and reconstruct null-only schemas truthfully. |
| `json-schema-07`, `json-schema-08` | Add canonical package guidance and provenance, and distinguish required properties from nullable values. |
| `json-schema-09`, `json-schema-10` | Preserve explicit null concrete defaults and add owner-approved fluent mixed defaults to union and any-of schemas. |
| `json-schema-13` | Reject non-string direct union members without coercion, warnings, or undocumented PHP errors. |
+| `json-schema-14` | Preserve branch-local enum ownership in nullable `anyOf` and reject the equivalent unrepresentable `oneOf` form. |
- **Architecture and lifecycle:** Factories, builders, serialization, and deserialization remain allocation-only and operation-local. Serializer retains one protected static list of ignored internal fields, inherited from Laravel and never mutated by package code; no other static or worker-lifetime state exists. No provider, binding, callback, external service, coroutine context, or worker cleanup is involved. Reference caches and counters exist only for one `fromArray()` call.
- **Reference-risk evidence:** The recursive implementation expanded about 130 KiB of 4,000-link input to 192 MiB and died near 200 KiB / 6,000 links at a 256 MiB limit. The iterative implementation handled 6,000 links in about 8 MiB and 79 ms. The original memory fatal is uncatchable and would terminate a Swoole worker and every concurrent coroutine it serves.
-- **Implementation:** The current builder surface, sum types, explicit-default state, reversible flags, unique arrays, and strict direct-union boundary are complete. Serialization preserves object shapes, numeric required names, final composition validity, and encoding exceptions. Reconstruction uses bounded iterative local references, strict recognized-keyword ownership, exact null/composition handling, and fail-closed unsupported assertions while continuing to ignore harmless annotations and extensions. Superseded coercion, recursive reference frames, false nullable prose, and blank-output fallback are removed.
+- **Implementation:** The current builder surface, sum types, explicit-default state, reversible flags, unique arrays, and strict direct-union boundary are complete. Serialization preserves object shapes, numeric required names, final composition validity, and encoding exceptions. Reconstruction uses bounded iterative local references, strict recognized-keyword ownership, exact null/composition handling, exact nullable-`oneOf` cardinality, branch-local enum ownership, and fail-closed unsupported assertions while continuing to ignore harmless annotations and extensions. Superseded coercion, recursive reference frames, false nullable prose, and blank-output fallback are removed.
- **Important rejected concerns:** No container/scoped binding, static factory, worker cache, registry, lock, `CoroutineContext`, configurable limits, graph engine, validator, keyword table, raw assertion bag, `NullType`, serializer cycle tracker, schema-valued additional-properties bridge, recursive object-default reinterpretation, or default-versus-branch validation was added. These mechanisms had no supported consumer or would duplicate a JSON Schema validator.
-- **Regression coverage:** Tests cover the current factory and contract surface; reversible flags; sum types and defaults; primitive, object, array, nullable, composition, and reference reconstruction; active-path and aggregate expansion limits; exact property/default JSON shapes; malformed recognized keywords; unsupported assertions; integer range boundaries; falsey and permissive forms; explicit null defaults; direct union-member failures; and JSON encoding exceptions. Counterfactual rows pin every corrected silent-loss and warning/error path.
+- **Regression coverage:** Tests cover the current factory and contract surface; reversible flags; sum types and defaults; primitive, object, array, nullable, composition, and reference reconstruction; exact nullable-`oneOf` matching; branch-local enum ownership; active-path and aggregate expansion limits; exact property/default JSON shapes; malformed recognized keywords; unsupported assertions; integer range boundaries; falsey and permissive forms; explicit null defaults; direct union-member failures; and JSON encoding exceptions. Counterfactual rows pin every corrected silent-loss and warning/error path.
- **Performance and compatibility:** Ordinary builder use adds only bounded local checks and object-shape classification. `fromArray()` is explicit cold work; its fixed keyword checks and counters replace recursive reference copying with a lower-memory loop and hard bounds. No request path gains I/O, synchronization, yielding, retries, container resolution, or retained worker state. Current Laravel-facing APIs and named arguments are preserved; concrete null defaults and sum-type defaults are additive approved widenings, and invalid inputs now fail instead of producing a weaker or unusable schema.
-- **Upstream handoff:** Current Laravel 13.x retains direct union-member coercion, unbounded recursive reference following, lossy reconstruction of scalar/constrained null and unsupported or malformed assertions, nested-composition loss, boolean `oneOf` loss, over-rejection of representable `items: true`, numeric object-map encoding, blank-string JSON failures, and invalid finally-empty compositions. Each can be upstreamed independently without Hypervel lifecycle machinery.
+- **Upstream handoff:** Current Laravel 13.x retains direct union-member coercion, unbounded recursive reference following, lossy reconstruction of scalar/constrained null and unsupported or malformed assertions, nullable-`oneOf` overlap, branch-local enum hoisting, nested-composition loss, boolean `oneOf` loss, over-rejection of representable `items: true`, numeric object-map encoding, blank-string JSON failures, and invalid finally-empty compositions. Each can be upstreamed independently without Hypervel lifecycle machinery.
- **Validation and review:** Changed tests passed throughout implementation; the complete JSON Schema suite, formatting, both PHPStan configurations, the full parallel components suite, Testbench package mode, dogfood, stale-marker scans, and `git diff --check` are green. Post-review corrections passed the complete package tests and both normal PHPStan configurations. Independent review reproduced the defect paths, rechecked neighboring behavior and performance, and signed off after every correction.
- **Assessment:** JSON Schema is current at the audited Laravel surface, coroutine-safe by operation-local construction, bounded against worker-fatal reference expansion, and truthful at every supported reconstruction and serialization boundary. Every accepted finding is resolved without a workaround, speculative mechanism, useful Laravel API removal, meaningful hot-path regression, stale code, unresolved defect, or deferred TODO.
diff --git a/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md b/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md
index c7acb4750..0f02d2b61 100644
--- a/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md
+++ b/docs/plans/2026-08-07-2015-json-schema-correctness-current-parity-and-bounded-reconstruction.md
@@ -91,9 +91,10 @@ Record low-confidence concerns under rejected or unresolved analysis. Do not imp
| `json-schema-08` | Documentation defect / Minor | Describe `nullable()` as permitting null, not making a property optional. |
| `json-schema-09` | Approved API improvement | Widen each concrete default setter with `null` while preserving its existing non-null domain. |
| `json-schema-10` | Approved API improvement | Add fluent `default(mixed)` to union and any-of without validating annotation values against branches. |
-| `json-schema-11` | Upstream reconstruction defect / Major | Reject unsupported JSON Schema 2020-12 assertions instead of silently weakening them; only a bare null branch may collapse into nullability. |
+| `json-schema-11` | Upstream reconstruction defect / Major | Reject unsupported JSON Schema 2020-12 assertions instead of silently weakening them; allow only bare null branches to collapse and preserve `oneOf`'s exact-match cardinality. |
| `json-schema-12` | Upstream reconstruction defect / Major | Reject recognized keyword values that would be coerced, dropped, or deferred; preserve permissive `items: true`; reject surviving nested compositions and empty input compositions. |
| `json-schema-13` | Upstream builder defect / Major | Reject non-string direct union members instead of coercing them, warning, or raising an undocumented PHP `Error`. |
+| `json-schema-14` | Upstream reconstruction defect / Major | Preserve branch-local enum ownership in nullable `anyOf`; reject the equivalent unrepresentable `oneOf` form. |
No accepted item adds request hot-path work. Builder serialization gains constant-time state checks and one property/default shape test. Deserialization work occurs only when `fromArray()` is explicitly called and is bounded more tightly than upstream.
@@ -292,7 +293,7 @@ protected const TYPE_SPECIFIC_KEYWORDS = [
];
```
-The multi-type union guard uses this constant. In `buildAnyOfComposition()`, first identify and remove null branches. Preserve the existing nullable-single-schema path by returning `null` before applying the general-composition guard; `normalizeUnions()` owns those collapsed forms and must continue accepting their type-specific siblings. Only the path that will construct a real `AnyOfType` rejects the constant's keys plus `type` and `oneOf`. After nullable collapse, reject any `anyOf` or `oneOf` keyword still present in the merged fragment so siblings, inline branches, and resolved references cannot silently replace one another. Continue preserving `title`, `description`, `enum`, `default`, and nullability. Continue ignoring unknown annotations such as `$schema`, `$comment`, `readOnly`, and `contentEncoding`; this package is not a general validator.
+The multi-type union guard uses this constant. In `buildAnyOfComposition()`, first identify and remove null branches. Preserve the existing nullable-single-schema path by returning `null` before applying the general-composition guard, except when only the surviving branch owns an array-form enum that excludes null. That form must remain a real `AnyOfType`; an outer sibling enum or a branch enum that includes null may still collapse. `normalizeUnions()` owns the collapsed forms and must continue accepting their type-specific siblings. Only the path that constructs a real `AnyOfType` rejects the constant's keys plus `type` and `oneOf`. After nullable collapse, reject any `anyOf` or `oneOf` keyword still present in the merged fragment so siblings, inline branches, and resolved references cannot silently replace one another. Continue preserving `title`, `description`, `enum`, `default`, and nullability. Continue ignoring unknown annotations such as `$schema`, `$comment`, `readOnly`, and `contentEncoding`; this package is not a general validator.
#### Enum shape
@@ -304,7 +305,7 @@ Reject unsupported standard assertions before they can be dropped. Keep one prot
The presence-based guard is deliberately conservative: value-level no-ops such as a bare `if` or empty `patternProperties` also throw. Do not add keyword-specific evaluation or a general validator. Standalone `then`, `else`, `minContains`, and `maxContains` remain ignored because they have no effect without the guarded owning assertion. Standard annotations and vendor extensions remain ignored.
-Only a branch whose sole key is `type` with value `"null"` or `["null"]` may collapse into outer nullability. A non-bare null branch in `anyOf` remains a real branch so supported assertions and annotations are preserved; the equivalent general `oneOf` is rejected because there is no `OneOfType`. The strict classifier and assertion guard must land together: otherwise a constrained null branch is discarded before the guard can see it. This intentionally rejects type-specific keywords on a null branch, even though those keywords are no-ops for null, for consistency with the existing null-only union constraint boundary.
+Only a branch whose sole key is `type` with value `"null"` or `["null"]` may collapse into outer nullability. A non-bare null branch in `anyOf` remains a real branch so supported assertions and annotations are preserved; the equivalent general `oneOf` is rejected because there is no `OneOfType`. A collapsed `oneOf` must contain exactly one bare null branch, and its surviving branch must declare a type that excludes null. A branch-local enum that excludes null cannot be hoisted from `oneOf`; reject it with the alternatives of including null in the enum or using the equivalent `anyOf` composition. Enum is the only supported cross-type assertion requiring this ownership rule; `const` is unsupported. The strict classifier and assertion guard must land together: otherwise a constrained null branch is discarded before the guard can see it. This intentionally rejects type-specific keywords on a null branch, even though those keywords are no-ops for null, for consistency with the existing null-only union constraint boundary.
### 5. Preserve valid composition semantics
@@ -371,11 +372,9 @@ Update `src/json-schema/README.md` in repository order:
3. a concise `Differences From Laravel` section;
4. `Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/JsonSchema`.
-Group the public differences, rather than writing a changelog:
+Record only the public API difference, not internal bug fixes:
-- Hypervel accepts explicit null defaults and provides fluent defaults on union and any-of schemas;
-- invalid JSON values raise `JsonException`, and finally-empty compositions throw instead of producing unusable output;
-- `fromArray()` rejects malformed or unsupported 2020-12 assertions it cannot preserve, accepts scalar and array-form null-only schemas and permissive `items: true`, and bounds active-reference depth as well as total expansion.
+- Hypervel accepts explicit null defaults and provides fluent defaults on union and any-of schemas.
Add `src/boost/docs/json-schema.md` in Laravel-docs prose and link it under **Digging Deeper**, between HTTP Client and Localization. Cover practical primitive, object, and array construction; `required()` versus `nullable()`; metadata and constraints; `unique()`, `union()`, and `anyOf()`; array/string output; `fromArray()`; local references; and the supported-subset failure boundary. State only user-actionable behavior, not serializer/deserializer internals.
@@ -409,7 +408,7 @@ Run each changed or new test file immediately. The final package suite must cove
11. direct and reconstructed `enum([])` preservation without Opis validation, with one short comment explaining that Opis's non-empty rule is stricter than JSON Schema 2020-12.
12. every unsupported 2020-12 assertion keyword rejected, while standalone `then`, `else`, `minContains`, `maxContains`, standard annotations, and vendor extensions remain accepted.
13. PHP integer endpoints preserved; out-of-range integral floats, fractional/nonnumeric count and length values, and malformed recognized keyword types rejected while numeric strings remain accepted.
-14. permissive `items: true` and `items: []`; rejected `items: false`; eager empty-input composition rejection; boolean `oneOf` branch rejection; and every sibling/branch/ref path that could leave a second composition after nullable collapse.
+14. permissive `items: true` and `items: []`; rejected `items: false`; eager empty-input composition rejection; boolean `oneOf` branch rejection; exact nullable-`oneOf` cardinality with corresponding `anyOf` exemption controls; branch-local enum ownership through inline and referenced branches; safe outer/nullable enums; preserved annotations; structural-sibling rejection; and every path that could leave a second composition after nullable collapse.
15. explicit null `type` and direct or reference-revealed non-string `$ref` values rejected at their owning boundaries rather than inferred or dropped.
16. direct union construction rejects every non-string member without warnings or PHP errors; null-valued inferred keywords reach their owning guards; range failures have an exact diagnostic; and unsupported assertions or surviving same-keyword compositions are rejected inside composition branches.
@@ -430,6 +429,7 @@ After focused files pass, run the complete `tests/JsonSchema` suite, targeted so
- No serializer cycle registry: a direct self-cycle is developer-created, fails during development, and cannot be constructed through `fromArray()`, whose circular refs already throw.
- No subtype dispatch redesign: exact-class serialization truthfully rejects unknown types and there is no documented subtype contract.
- No `NullType`: null-only output is representable through the existing union/any-of nullability model.
+- No `OneOfType` or general composition abstraction: the supported nullable subset is handled by bounded semantic guards.
- No recursive object-default shape conversion: only the top-level object intent is known.
- No rejection or Opis validation of empty enums: the 2020-12 contract permits them.
- No runtime dependency change: Opis remains test-only.
@@ -441,7 +441,7 @@ After implementation, validation, self-review, and code-review sign-off:
- add the final JSON Schema work unit to the companion audit ledger, including the upstream-shared defects, worker-fatal reference risk, accepted API widenings, and rejected machinery;
- set the core audit routing index to the active/completed JSON Schema work unit, add only genuine cross-package dependency rows if implementation discovers one, and check `json-schema` complete;
- record the Laravel-facing result: current APIs restored; explicit-null and sum-type defaults intentionally widened; malformed or unsupported lossy reconstruction and final-invalid output rejected; no useful Laravel API removed;
-- name the unchanged Laravel 13.x defects in the owner summary so the owner can decide whether to upstream them: direct union-member coercion, unbounded recursive reference following, lossy reconstruction (including scalar null, constrained null branches, unsupported 2020-12 assertions, coerced/dropped keyword values, nested composition loss, and boolean `oneOf` branches), over-rejection of representable `items: true`, numeric object-map encoding, blank-string JSON failures, and invalid empty compositions;
+- name the unchanged Laravel 13.x defects in the owner summary so the owner can decide whether to upstream them: direct union-member coercion, unbounded recursive reference following, lossy reconstruction (including scalar null, constrained null branches, nullable `oneOf` overlap, branch-local enum hoisting, unsupported 2020-12 assertions, coerced/dropped keyword values, nested composition loss, and boolean `oneOf` branches), over-rejection of representable `items: true`, numeric object-map encoding, blank-string JSON failures, and invalid empty compositions;
- leave no TODO or deferred accepted finding.
Then provide the owner with the complete pre-commit summary required by the core audit workflow and wait for explicit approval. Do not create any source, test, documentation, ledger, or bookkeeping commit before that approval.
diff --git a/src/boost/docs/json-schema.md b/src/boost/docs/json-schema.md
index c83f1fdba..8e25e6cc3 100644
--- a/src/boost/docs/json-schema.md
+++ b/src/boost/docs/json-schema.md
@@ -239,9 +239,13 @@ Only local references are supported. An `InvalidArgumentException` will be throw
The `fromArray` method can reconstruct schemas that use the same types, metadata, and constraints available through the fluent builder. It also accepts schemas whose only allowed type is `null`, whether the type is written as a string or as an array.
+When the `type` keyword is omitted, Hypervel infers it whenever the supported keywords identify one type unambiguously. For example, `['minLength' => 1]` is reconstructed as a string schema and therefore no longer accepts numbers or `null`. An `InvalidArgumentException` is thrown when no single type can be determined.
+
When working with schemas represented as PHP arrays, setting `items` to `true` or `[]` is treated the same as omitting the item constraint. Likewise, setting `additionalProperties` to `true` or `[]` preserves the default behavior of allowing additional properties.
-A `oneOf` schema may only be reconstructed when it contains one schema and a branch whose only keyword is `"type": "null"`. In this case, the schema is reconstructed as nullable.
+A `oneOf` schema may only be reconstructed as nullable when it contains exactly one branch whose only keyword is `"type": "null"` and one other branch whose `type` excludes `null`. This preserves the requirement that exactly one branch must match.
+
+When a nullable `anyOf` keeps an enum only on its non-null branch, the composition is preserved instead of moving the enum outside the branch. The same form cannot be reconstructed as nullable for `oneOf`; include `null` in the enum or use an equivalent `anyOf` composition instead.
Annotations and vendor extensions that are not represented by the builder are ignored.
diff --git a/src/json-schema/README.md b/src/json-schema/README.md
index 68c666fac..cd3a0ce60 100644
--- a/src/json-schema/README.md
+++ b/src/json-schema/README.md
@@ -5,6 +5,6 @@ Documentation: https://hypervel.org/docs/json-schema
## Differences From Laravel
-Hypervel accepts explicit `null` defaults and provides fluent defaults for union and any-of schemas. Invalid JSON values and finally-empty compositions throw instead of producing unusable output. `fromArray()` also rejects malformed or unsupported JSON Schema 2020-12 assertions it cannot preserve, supports scalar and array-form null-only schemas and permissive `items: true`, and bounds both reference depth and total expansion.
+Hypervel accepts explicit `null` defaults and provides fluent defaults for union and any-of schemas.
Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/JsonSchema
diff --git a/src/json-schema/src/Deserializer.php b/src/json-schema/src/Deserializer.php
index a2a5202f7..4a16d81a0 100644
--- a/src/json-schema/src/Deserializer.php
+++ b/src/json-schema/src/Deserializer.php
@@ -167,7 +167,9 @@ protected function buildAnyOfComposition(array $schema, array $refs = []): ?Type
}
}
- if ($nullable && count($branches) === 1) {
+ if ($nullable
+ && count($branches) === 1
+ && ! $this->hasBranchOnlyEnumExcludingNull($schema, $branches[0][0])) {
return null;
}
@@ -650,7 +652,7 @@ protected function normalizeUnions(array $schema, array $refs = []): array
throw new InvalidArgumentException("The JSON Schema [{$key}] keyword must be a non-empty array.");
}
- $nullable = false;
+ $nullBranches = 0;
$branches = [];
foreach ($schema[$key] as $branch) {
@@ -663,13 +665,21 @@ protected function normalizeUnions(array $schema, array $refs = []): array
[$branch, $branchRefs] = $this->resolveRef($branch, $refs);
if ($this->isNullBranch($branch)) {
- $nullable = true;
+ ++$nullBranches;
} else {
$branches[] = [$branch, $branchRefs];
}
}
- if (! $nullable || count($branches) !== 1) {
+ // "oneOf" accepts an instance only when exactly one branch matches, so its nullable
+ // collapse must not allow a second null match.
+ if ($key === 'oneOf' && $nullBranches > 1) {
+ throw new InvalidArgumentException(
+ 'A nullable "oneOf" must contain exactly one bare "null" branch.'
+ );
+ }
+
+ if ($nullBranches === 0 || count($branches) !== 1) {
throw new InvalidArgumentException(
"Only a nullable \"{$key}\" (a single schema plus a bare \"null\" branch) is supported."
);
@@ -677,6 +687,19 @@ protected function normalizeUnions(array $schema, array $refs = []): array
[$branch, $branchRefs] = $branches[0];
+ if ($key === 'oneOf' && $this->mayAcceptNull($branch)) {
+ throw new InvalidArgumentException(
+ 'A nullable "oneOf" schema branch must declare a type that excludes "null".'
+ );
+ }
+
+ if ($key === 'oneOf' && $this->hasBranchOnlyEnumExcludingNull($schema, $branch)) {
+ throw new InvalidArgumentException(
+ 'A branch-local [enum] that excludes null cannot be collapsed from a nullable "oneOf"; '
+ . 'include null in the enum or use an equivalent "anyOf" composition.'
+ );
+ }
+
$siblings = $schema;
unset($siblings[$key]);
@@ -715,11 +738,43 @@ protected function isNullBranch(array $branch): bool
return false;
}
- $type = $branch['type'] ?? null;
+ $type = $branch['type'];
return $type === 'null' || $type === ['null'];
}
+ /**
+ * Determine if the schema branch may accept null.
+ *
+ * @param array $branch
+ */
+ protected function mayAcceptNull(array $branch): bool
+ {
+ if (! array_key_exists('type', $branch)) {
+ // Without "type", the branch places no constraint on the instance type and matches null.
+ return true;
+ }
+
+ $type = $branch['type'];
+
+ return $type === 'null'
+ || (is_array($type) && in_array('null', $type, true));
+ }
+
+ /**
+ * Determine if the branch alone constrains an enum that excludes null.
+ *
+ * @param array $schema
+ * @param array $branch
+ */
+ protected function hasBranchOnlyEnumExcludingNull(array $schema, array $branch): bool
+ {
+ return ! array_key_exists('enum', $schema)
+ && array_key_exists('enum', $branch)
+ && is_array($branch['enum'])
+ && ! in_array(null, $branch['enum'], true);
+ }
+
/**
* Resolve a local "$ref" against the root schema, merging sibling keys.
*
diff --git a/tests/JsonSchema/DeserializerTest.php b/tests/JsonSchema/DeserializerTest.php
index e7bad81e3..c3190f828 100644
--- a/tests/JsonSchema/DeserializerTest.php
+++ b/tests/JsonSchema/DeserializerTest.php
@@ -937,6 +937,291 @@ public function testNullableSingleSchemaCompositionsKeepSupportedSiblingConstrai
}
}
+ public function testItRejectsDuplicateBareNullBranchesInOneOf(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'A nullable "oneOf" must contain exactly one bare "null" branch.'
+ ));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ ['type' => 'string'],
+ ['type' => 'null'],
+ ['type' => ['null']],
+ ],
+ ]);
+ }
+
+ public function testAnyOfMayContainDuplicateBareNullBranches(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'null'],
+ ['type' => ['null']],
+ ],
+ ]);
+
+ $this->assertSame(['type' => ['string', 'null']], $type->toArray());
+ }
+
+ public function testItRejectsDuplicateBareNullBranchesResolvedThroughReferencesInOneOf(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'A nullable "oneOf" must contain exactly one bare "null" branch.'
+ ));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ ['type' => 'string'],
+ ['$ref' => '#/$defs/nothing'],
+ ['$ref' => '#/$defs/nothing'],
+ ],
+ '$defs' => [
+ 'nothing' => ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ #[DataProvider('overlappingNullableOneOfProvider')]
+ public function testItRejectsNullableOneOfBranchesThatMayAcceptNull(array $branch): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'A nullable "oneOf" schema branch must declare a type that excludes "null".'
+ ));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ $branch,
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public static function overlappingNullableOneOfProvider(): array
+ {
+ return [
+ 'declared nullable type' => [['type' => ['string', 'null']]],
+ 'annotated null type' => [['type' => 'null', 'title' => 'No value']],
+ 'typeless string constraint' => [['minLength' => 2]],
+ 'typeless object constraint' => [['properties' => ['name' => ['type' => 'string']]]],
+ ];
+ }
+
+ public function testItRejectsANullAcceptingOneOfBranchResolvedThroughAReference(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'A nullable "oneOf" schema branch must declare a type that excludes "null".'
+ ));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ ['$ref' => '#/$defs/maybe'],
+ ['type' => 'null'],
+ ],
+ '$defs' => [
+ 'maybe' => ['type' => ['string', 'null']],
+ ],
+ ]);
+ }
+
+ #[DataProvider('overlappingNullableAnyOfProvider')]
+ public function testNullableAnyOfMayContainBranchesThatAlsoAcceptNull(array $branch, array $expected): void
+ {
+ $type = JsonSchema::fromArray([
+ 'anyOf' => [
+ $branch,
+ ['type' => 'null'],
+ ],
+ ]);
+
+ $this->assertSame($expected, $type->toArray());
+ }
+
+ public static function overlappingNullableAnyOfProvider(): array
+ {
+ return [
+ 'declared nullable type' => [
+ ['type' => ['string', 'null']],
+ ['type' => ['string', 'null']],
+ ],
+ 'annotated null type' => [
+ ['type' => 'null', 'title' => 'No value'],
+ ['title' => 'No value', 'type' => ['null']],
+ ],
+ 'typeless string constraint' => [
+ ['minLength' => 2],
+ ['minLength' => 2, 'type' => ['string', 'null']],
+ ],
+ 'typeless object constraint' => [
+ ['properties' => ['name' => ['type' => 'string']]],
+ [
+ 'properties' => ['name' => ['type' => 'string']],
+ 'type' => ['object', 'null'],
+ ],
+ ],
+ ];
+ }
+
+ #[DataProvider('branchOnlyEnumAnyOfProvider')]
+ public function testItPreservesABranchOnlyEnumInNullableAnyOf(array $schema): void
+ {
+ $type = JsonSchema::fromArray($schema);
+
+ $this->assertInstanceOf(AnyOfType::class, $type);
+ $this->assertSame([
+ 'anyOf' => [
+ ['enum' => ['draft', 'published'], 'type' => 'string'],
+ ['type' => 'null'],
+ ],
+ ], $type->toArray());
+ }
+
+ public static function branchOnlyEnumAnyOfProvider(): array
+ {
+ return [
+ 'inline branch' => [[
+ 'anyOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ]],
+ 'referenced branch' => [[
+ 'anyOf' => [
+ ['$ref' => '#/$defs/status'],
+ ['type' => 'null'],
+ ],
+ '$defs' => [
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'published']],
+ ],
+ ]],
+ ];
+ }
+
+ #[DataProvider('collapsibleNullableEnumProvider')]
+ public function testItCollapsesNullableCompositionsWhenEnumOwnershipIsPreserved(array $schema, array $expected): void
+ {
+ $this->assertSame($expected, JsonSchema::fromArray($schema)->toArray());
+ }
+
+ public static function collapsibleNullableEnumProvider(): array
+ {
+ return [
+ 'outer enum' => [[
+ 'enum' => ['draft', 'published'],
+ 'anyOf' => [
+ ['type' => 'string'],
+ ['type' => 'null'],
+ ],
+ ], [
+ 'enum' => ['draft', 'published'],
+ 'type' => ['string', 'null'],
+ ]],
+ 'equal branch and outer enums' => [[
+ 'enum' => ['draft', 'published'],
+ 'anyOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ], [
+ 'enum' => ['draft', 'published'],
+ 'type' => ['string', 'null'],
+ ]],
+ 'equal branch and outer enums in oneOf' => [[
+ 'enum' => ['draft', 'published'],
+ 'oneOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ], [
+ 'enum' => ['draft', 'published'],
+ 'type' => ['string', 'null'],
+ ]],
+ 'branch enum includes null' => [[
+ 'anyOf' => [
+ ['type' => 'string', 'enum' => ['draft', null]],
+ ['type' => 'null'],
+ ],
+ ], [
+ 'enum' => ['draft', null],
+ 'type' => ['string', 'null'],
+ ]],
+ 'oneOf branch enum includes null' => [[
+ 'oneOf' => [
+ ['type' => 'string', 'enum' => ['draft', null]],
+ ['type' => 'null'],
+ ],
+ ], [
+ 'enum' => ['draft', null],
+ 'type' => ['string', 'null'],
+ ]],
+ ];
+ }
+
+ public function testItRejectsConflictingBranchAndOuterEnums(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'Conflicting [enum] between a "anyOf" branch and its sibling keys.'
+ ));
+
+ JsonSchema::fromArray([
+ 'enum' => ['archived'],
+ 'anyOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public function testItRejectsABranchOnlyEnumInNullableOneOf(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'A branch-local [enum] that excludes null cannot be collapsed from a nullable "oneOf"; '
+ . 'include null in the enum or use an equivalent "anyOf" composition.'
+ ));
+
+ JsonSchema::fromArray([
+ 'oneOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public function testItRejectsStructuralSiblingsWhenPreservingABranchOnlyEnumAnyOf(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException(
+ 'Structural keywords [minLength] are not supported alongside a general JSON Schema anyOf.'
+ ));
+
+ JsonSchema::fromArray([
+ 'minLength' => 2,
+ 'anyOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ]);
+ }
+
+ public function testItPreservesAnnotationsWhenPreservingABranchOnlyEnumAnyOf(): void
+ {
+ $type = JsonSchema::fromArray([
+ 'title' => 'Status',
+ 'anyOf' => [
+ ['type' => 'string', 'enum' => ['draft', 'published']],
+ ['type' => 'null'],
+ ],
+ ]);
+
+ $this->assertSame([
+ 'title' => 'Status',
+ 'anyOf' => [
+ ['enum' => ['draft', 'published'], 'type' => 'string'],
+ ['type' => 'null'],
+ ],
+ ], $type->toArray());
+ }
+
#[DataProvider('emptyInputCompositionProvider')]
public function testItRejectsEmptyInputCompositions(string $keyword): void
{