diff --git a/docs/docs.json b/docs/docs.json
index 519997c..184b583 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -46,6 +46,7 @@
"json-schema/schema-types/integer",
"json-schema/schema-types/boolean",
"json-schema/schema-types/union",
+ "json-schema/schema-types/typeless",
"json-schema/schema-types/null"
]
},
diff --git a/docs/json-schema/advanced/definitions-refs.mdx b/docs/json-schema/advanced/definitions-refs.mdx
index 3fe554b..048b44d 100644
--- a/docs/json-schema/advanced/definitions-refs.mdx
+++ b/docs/json-schema/advanced/definitions-refs.mdx
@@ -317,6 +317,69 @@ $modernSchema = Schema::object('user', SchemaVersion::Draft_2019_09)
The package automatically uses the correct keyword based on the schema version.
+## Plain-Name Anchors (`$anchor`)
+
+Anchors provide a plain-name identifier for a schema that can be targeted by a `$ref` using the `#anchor` fragment syntax, instead of a full JSON Pointer path. Use `anchor()` to set one (Draft 2019-09+):
+
+```php
+use Cortex\JsonSchema\Schema;
+use Cortex\JsonSchema\Enums\SchemaVersion;
+
+$userSchema = Schema::object('user', SchemaVersion::Draft_2019_09)
+ ->addDefinition(
+ 'address',
+ Schema::object()
+ ->anchor('address') // Plain-name anchor
+ ->properties(
+ Schema::string('street')->required(),
+ Schema::string('city')->required()
+ )
+ )
+ ->properties(
+ Schema::string('name')->required(),
+ // Reference the definition by its anchor instead of a pointer
+ Schema::object('home_address')->ref('#address')
+ );
+```
+
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "type": "object",
+ "title": "user",
+ "$defs": {
+ "address": {
+ "type": "object",
+ "$anchor": "address",
+ "properties": {
+ "street": {
+ "type": "string"
+ },
+ "city": {
+ "type": "string"
+ }
+ },
+ "required": ["street", "city"]
+ }
+ },
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "home_address": {
+ "$ref": "#address"
+ }
+ },
+ "required": ["name"]
+}
+```
+
+
+
+`$anchor` requires Draft 2019-09 or later. Using `anchor()` on an older schema version throws a `SchemaException`.
+
+
## Complex Real-World Example
Here's a comprehensive e-commerce schema using definitions:
diff --git a/docs/json-schema/advanced/dependent-schemas.mdx b/docs/json-schema/advanced/dependent-schemas.mdx
index 8a8e875..0985a87 100644
--- a/docs/json-schema/advanced/dependent-schemas.mdx
+++ b/docs/json-schema/advanced/dependent-schemas.mdx
@@ -314,6 +314,78 @@ $profileSchema = Schema::object('profile', SchemaVersion::Draft_2019_09)
]);
```
+## Dependent Required Properties
+
+When you only need to require *other* properties based on the presence of a property — without applying a full sub-schema — use `dependentRequired()`. It maps directly to the `dependentRequired` keyword (Draft 2019-09+), which was split out from the legacy `dependencies` keyword.
+
+```php
+use Cortex\JsonSchema\Schema;
+use Cortex\JsonSchema\Enums\SchemaVersion;
+
+$paymentSchema = Schema::object('payment', SchemaVersion::Draft_2019_09)
+ ->properties(
+ Schema::string('amount')->required(),
+ Schema::string('credit_card'),
+ Schema::string('billing_address'),
+ Schema::string('cvv')
+ )
+ // When 'credit_card' is present, 'billing_address' and 'cvv' become required
+ ->dependentRequired([
+ 'credit_card' => ['billing_address', 'cvv'],
+ ]);
+
+// Valid - no credit card, so no extra requirements
+$paymentSchema->isValid([
+ 'amount' => '100.00'
+]); // true
+
+// Valid - credit card with its required companions
+$paymentSchema->isValid([
+ 'amount' => '100.00',
+ 'credit_card' => '4111111111111111',
+ 'billing_address' => '123 Main St',
+ 'cvv' => '123'
+]); // true
+
+// Invalid - credit card present but missing dependent fields
+$paymentSchema->isValid([
+ 'amount' => '100.00',
+ 'credit_card' => '4111111111111111'
+]); // false (billing_address and cvv are required)
+```
+
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "type": "object",
+ "title": "payment",
+ "properties": {
+ "amount": {
+ "type": "string"
+ },
+ "credit_card": {
+ "type": "string"
+ },
+ "billing_address": {
+ "type": "string"
+ },
+ "cvv": {
+ "type": "string"
+ }
+ },
+ "required": ["amount"],
+ "dependentRequired": {
+ "credit_card": ["billing_address", "cvv"]
+ }
+}
+```
+
+
+
+Reach for `dependentRequired()` when the dependency is purely about *requiring other properties*. Use `dependentSchema()` / `dependentSchemas()` when you need richer conditional validation (types, patterns, `if`/`then`/`else`, etc.).
+
+
## API Configuration Example
Real-world example for API endpoint configuration:
diff --git a/docs/json-schema/code-generation/from-json.mdx b/docs/json-schema/code-generation/from-json.mdx
index 1cbdd18..db4a435 100644
--- a/docs/json-schema/code-generation/from-json.mdx
+++ b/docs/json-schema/code-generation/from-json.mdx
@@ -52,7 +52,7 @@ $schema->isValid($userData); // true
## Import from Array
-Import a schema from a PHP array representation:
+Import a schema from a PHP array representation using `Schema::fromArray()`:
```php
$schemaArray = [
@@ -93,9 +93,13 @@ $schemaArray = [
];
// Import from array
-$productSchema = Schema::fromJson($schemaArray);
+$productSchema = Schema::fromArray($schemaArray);
```
+
+`Schema::fromArray()` is a convenience wrapper around `Schema::fromJson()` for when you already have a decoded array. Both accept an optional `SchemaVersion` as the second argument; `Schema::fromJson()` additionally accepts a raw JSON string.
+
+
```php
// Validate product data
@@ -237,6 +241,44 @@ $singleUserResponse = [
$apiSchema->isValid($singleUserResponse); // true
```
+## Supported Keywords
+
+The converter performs a full round-trip of the JSON Schema vocabulary, so importing and then re-exporting a schema preserves its keywords. Every schema type receives coverage for the following:
+
+- **Metadata** - `title`, `description`, `$comment`, `examples`, `default`, `deprecated`, `readOnly`, `writeOnly`
+- **Validation** - `enum`, `const` (including boolean and array/object values), `format`
+- **Composition** - `allOf`, `anyOf`, `oneOf`, `not`
+- **Conditionals** - `if` / `then` / `else`
+- **References & identity** - `$ref`, `$anchor`, `$defs`, and draft-07 `definitions`
+- **Object keywords** - `properties`, `required`, `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, `maxProperties`, `unevaluatedProperties`, `dependentSchemas`, `dependentRequired`
+- **Array keywords** - `items`, `prefixItems`, draft-07 tuple-style `items` arrays, `additionalItems`, `unevaluatedItems`, `contains`, `minContains`, `maxContains`, `minItems`, `maxItems`, `uniqueItems`
+
+
+Version-specific keywords are imported using the detected (or supplied) `SchemaVersion`. For example, `unevaluatedProperties`, `dependentSchemas`, `dependentRequired`, and `$anchor` require Draft 2019-09+, while `prefixItems` requires Draft 2020-12.
+
+
+### Typeless Schemas
+
+Schemas without a `type` keyword that carry structural keywords (such as `$ref`, `$defs`, `allOf`, `properties`, or `required`) are imported as a `TypelessSchema` — the converter does **not** expand them into a union of all types. This keeps composition-only and definition-only documents intact:
+
+```php
+// A definition-only document with no top-level "type"
+$schema = Schema::fromArray([
+ '$defs' => [
+ 'product' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ ],
+ ],
+]);
+
+$schema->toArray(); // No "type" key is emitted
+```
+
+See [Typeless Schema](/json-schema/schema-types/typeless) for building typeless schemas with the fluent builder via `Schema::typeless()`.
+
## Common Use Cases
diff --git a/docs/json-schema/introduction.mdx b/docs/json-schema/introduction.mdx
index 09fc1bf..0f7dd79 100644
--- a/docs/json-schema/introduction.mdx
+++ b/docs/json-schema/introduction.mdx
@@ -126,6 +126,7 @@ All schema types support common properties like title, description, examples, de
- **Boolean Schema** - True/false validation with default values
- **Null Schema** - Null value validation and nullable type support
- **Union Schema** - Multi-type schemas with nullable support and discriminated unions
+ - **Typeless Schema** - Composition-only and definition-only documents with no `type` keyword
## Advanced Features
@@ -137,10 +138,11 @@ All schema types support common properties like title, description, examples, de
- **not** - Schema must not match
### Modern JSON Schema Features
-- **Definitions & References** - Reusable schema components with `$ref`
-- **Unevaluated Properties** - Advanced property validation (Draft 2019-09+)
-- **Dependent Schemas** - Property-dependent validation rules
+- **Definitions & References** - Reusable schema components with `$ref`, `$defs`, and plain-name `$anchor`
+- **Unevaluated Properties** - Advanced property/item validation (Draft 2019-09+)
+- **Dependent Schemas** - Property-dependent validation rules, including `dependentRequired`
- **Pattern Properties** - Regex-based property validation
+- **Tuple Validation** - `prefixItems` (Draft 2020-12) and legacy `items`/`additionalItems` tuples (Draft-07)
- **Contains Validation** - Array item existence validation
### Code Generation
@@ -170,4 +172,6 @@ The package defaults to Draft 2020-12 for the latest features. Use Draft-06 for
| `minContains`/`maxContains` | ❌ | ❌ | ✅ | ✅ |
| `unevaluatedProperties` | ❌ | ❌ | ✅ | ✅ |
| `dependentSchemas` | ❌ | ❌ | ✅ | ✅ |
+| `dependentRequired` | ❌ | ❌ | ✅ | ✅ |
+| `$anchor` | ❌ | ❌ | ✅ | ✅ |
| `prefixItems` | ❌ | ❌ | ❌ | ✅ |
diff --git a/docs/json-schema/schema-types/array.mdx b/docs/json-schema/schema-types/array.mdx
index 2e0a38a..c096ee6 100644
--- a/docs/json-schema/schema-types/array.mdx
+++ b/docs/json-schema/schema-types/array.mdx
@@ -257,6 +257,66 @@ $flexibleTupleSchema->isValid(['John', 30, 123]); // false (additi
- **Use `prefixItems()`** when you need positional validation (tuples) or when the first N items have specific schemas
- **Combine both** when you need a fixed prefix followed by additional items with a different schema
+### Legacy Tuples with tupleItems (Draft-07 and earlier)
+
+In Draft-07 and Draft-06, tuple validation is expressed by setting `items` to an *array* of schemas, with `additionalItems` controlling whatever follows the tuple. Use `tupleItems()` and `additionalItems()` for this legacy style:
+
+```php
+use Cortex\JsonSchema\Enums\SchemaVersion;
+
+$coordinateSchema = Schema::array('coordinates', SchemaVersion::Draft_07)
+ ->tupleItems([
+ Schema::number()->description('latitude'),
+ Schema::number()->description('longitude'),
+ ])
+ ->additionalItems(false) // No items beyond the tuple
+ ->description('Geographic coordinates [lat, lng]');
+
+$coordinateSchema->isValid([51.5074, -0.1278]); // true
+$coordinateSchema->isValid([51.5074, -0.1278, 100]); // false (additional items not allowed)
+```
+
+
+```json
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "array",
+ "title": "coordinates",
+ "items": [
+ {
+ "type": "number",
+ "description": "latitude"
+ },
+ {
+ "type": "number",
+ "description": "longitude"
+ }
+ ],
+ "additionalItems": false,
+ "description": "Geographic coordinates [lat, lng]"
+}
+```
+
+
+You can also pass a schema to `additionalItems()` so that any items after the tuple must match it:
+
+```php
+$recordSchema = Schema::array('record', SchemaVersion::Draft_07)
+ ->tupleItems([
+ Schema::string()->description('name'),
+ Schema::integer()->description('age'),
+ ])
+ ->additionalItems(Schema::string()); // Extra items must be strings
+
+$recordSchema->isValid(['John', 30]); // true
+$recordSchema->isValid(['John', 30, 'admin', 'staff']); // true
+$recordSchema->isValid(['John', 30, 99]); // false (extra item not a string)
+```
+
+
+`tupleItems()` / `additionalItems()` map to the Draft-07 array-form `items` + `additionalItems` keywords. For Draft 2020-12, prefer `prefixItems()` combined with `items()`, which supersede them.
+
+
## Contains Validation
Validate that an array contains specific items:
diff --git a/docs/json-schema/schema-types/object.mdx b/docs/json-schema/schema-types/object.mdx
index 9acc62c..92b8e80 100644
--- a/docs/json-schema/schema-types/object.mdx
+++ b/docs/json-schema/schema-types/object.mdx
@@ -503,8 +503,24 @@ $conditionalSchema = Schema::object('payment', SchemaVersion::Draft_2019_09)
Schema::string('card_number')->required()
))
);
+
+// Dependent required properties (Draft 2019-09+)
+$cardSchema = Schema::object('payment', SchemaVersion::Draft_2019_09)
+ ->properties(
+ Schema::string('credit_card'),
+ Schema::string('billing_address'),
+ Schema::string('cvv')
+ )
+ // If 'credit_card' is present, these properties become required
+ ->dependentRequired([
+ 'credit_card' => ['billing_address', 'cvv'],
+ ]);
```
+
+See [Dependent Schemas](/json-schema/advanced/dependent-schemas) for a deeper look at `dependentSchema()`, `dependentSchemas()`, and `dependentRequired()`.
+
+
## Validation Examples
```php
diff --git a/docs/json-schema/schema-types/typeless.mdx b/docs/json-schema/schema-types/typeless.mdx
new file mode 100644
index 0000000..0a9365f
--- /dev/null
+++ b/docs/json-schema/schema-types/typeless.mdx
@@ -0,0 +1,137 @@
+---
+title: Typeless Schema
+description: 'Composition-only and definition-only schemas without a type keyword'
+icon: 'circle-off'
+---
+
+Typeless schemas represent JSON Schema documents that intentionally omit the `type` keyword. They are used for composition-only schemas (`allOf`/`anyOf`/`oneOf`/`not`), definition-only documents (`$defs`), or any schema where structural keywords carry the full meaning without declaring a value type.
+
+Use `Schema::typeless()` to build one — it returns a dedicated `TypelessSchema` instance.
+
+## Composition-Only Schemas
+
+When validation is expressed entirely through composition keywords, no top-level `type` is needed:
+
+```php
+use Cortex\JsonSchema\Schema;
+
+$schema = Schema::typeless('shape')
+ ->oneOf(
+ Schema::object()->properties(
+ Schema::string('kind')->const('circle'),
+ Schema::number('radius')->required()
+ ),
+ Schema::object()->properties(
+ Schema::string('kind')->const('square'),
+ Schema::number('size')->required()
+ )
+ );
+```
+
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "shape",
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "kind": { "const": "circle" },
+ "radius": { "type": "number" }
+ },
+ "required": ["radius"]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "kind": { "const": "square" },
+ "size": { "type": "number" }
+ },
+ "required": ["size"]
+ }
+ ]
+}
+```
+
+
+## Definition-Only Documents
+
+Typeless schemas are also the right choice for documents that only declare reusable definitions:
+
+```php
+$definitions = Schema::typeless()
+ ->addDefinition('address', Schema::object()->properties(
+ Schema::string('street')->required(),
+ Schema::string('city')->required()
+ ));
+// Output contains "$defs" but no "type"
+```
+
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$defs": {
+ "address": {
+ "type": "object",
+ "properties": {
+ "street": { "type": "string" },
+ "city": { "type": "string" }
+ },
+ "required": ["street", "city"]
+ }
+ }
+}
+```
+
+
+## Typeless vs Union
+
+
+A typeless schema is **not** the same as a union. A [Union Schema](/json-schema/schema-types/union) explicitly declares which value types are accepted (e.g. `["string", "integer"]`). A typeless schema has no `type` keyword at all — it constrains data through composition, references, or structural keywords instead.
+
+
+| | Typeless Schema | Union Schema |
+|---|---|---|
+| `type` keyword | Omitted | Present (single or array) |
+| Use case | Composition, `$defs`, `$ref` | Multi-type value acceptance |
+| Factory | `Schema::typeless()` | `Schema::union([...])` |
+
+## Importing Typeless Schemas
+
+When importing existing JSON Schema documents, schemas without a `type` keyword that carry structural keywords (`$ref`, `$defs`, `allOf`, `properties`, `required`, etc.) are converted to a `TypelessSchema` automatically — the converter does not expand them into a union of all types.
+
+```php
+$schema = Schema::fromArray([
+ '$defs' => [
+ 'product' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => ['type' => 'string'],
+ ],
+ ],
+ ],
+]);
+
+$schema->toArray(); // No "type" key is emitted
+```
+
+See [From JSON Schema](/json-schema/code-generation/from-json) for full import coverage.
+
+## Common Use Cases
+
+
+
+ Documents that only define reusable `$defs` for other schemas to reference.
+
+
+ Composition-only schemas using `oneOf`/`anyOf` to discriminate between shapes without a top-level type.
+
+
+ Root schemas that are purely a `$ref` pointer to another definition.
+
+
+ Schemas where `if`/`then`/`else` or `allOf` carry the full validation logic.
+
+
diff --git a/docs/json-schema/schema-types/union.mdx b/docs/json-schema/schema-types/union.mdx
index cfdfbcc..d8cedff 100644
--- a/docs/json-schema/schema-types/union.mdx
+++ b/docs/json-schema/schema-types/union.mdx
@@ -110,6 +110,10 @@ $responseDataSchema = Schema::union([
->description('API response data in various formats');
```
+
+Need a schema with no `type` keyword? See [Typeless Schema](/json-schema/schema-types/typeless) for composition-only and definition-only documents.
+
+
## Union with Specific Schemas
Use `oneOf` to define specific schemas for each type:
diff --git a/ecs.php b/ecs.php
index 4162ac5..49dd243 100644
--- a/ecs.php
+++ b/ecs.php
@@ -31,7 +31,6 @@
psr12: true,
common: true,
cleanCode: true,
- strict: true,
)
->withPhpCsFixerSets(
php83Migration: true,
diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php
index 3494d15..7246bd6 100644
--- a/src/Converters/JsonConverter.php
+++ b/src/Converters/JsonConverter.php
@@ -19,6 +19,7 @@
use Cortex\JsonSchema\Types\IntegerSchema;
use Cortex\JsonSchema\Contracts\JsonSchema;
use Cortex\JsonSchema\Types\AbstractSchema;
+use Cortex\JsonSchema\Types\TypelessSchema;
use Cortex\JsonSchema\Exceptions\SchemaException;
class JsonConverter implements Converter
@@ -35,7 +36,6 @@ class JsonConverter implements Converter
*/
public function __construct(string|array $json, SchemaVersion $schemaVersion)
{
- // Parse JSON string if provided
if (is_string($json)) {
try {
/** @var array|string $decoded */
@@ -53,7 +53,6 @@ public function __construct(string|array $json, SchemaVersion $schemaVersion)
$this->data = $json;
}
- // Extract schema version from JSON if provided
if (isset($this->data['$schema']) && is_string($this->data['$schema'])) {
$this->schemaVersion = $this->detectSchemaVersion($this->data['$schema']);
} else {
@@ -66,7 +65,21 @@ public function convert(): JsonSchema
$type = $this->data['type'] ?? null;
$title = isset($this->data['title']) && is_string($this->data['title']) ? $this->data['title'] : null;
- // Handle union types when type is an array
+ if ($this->shouldUseTypelessSchema()) {
+ return $this->createTypelessSchema($title);
+ }
+
+ if ($type === null && ($inferredType = $this->inferTypeFromKeywords()) !== null) {
+ return match ($inferredType) {
+ 'string' => $this->createStringSchema($title),
+ 'number' => $this->createNumberSchema($title),
+ 'integer' => $this->createIntegerSchema($title),
+ 'boolean' => $this->createBooleanSchema($title),
+ 'array' => $this->createArraySchema($title),
+ default => $this->createUnionSchema($title),
+ };
+ }
+
if (is_array($type)) {
return $this->createUnionSchema($title);
}
@@ -79,10 +92,10 @@ public function convert(): JsonSchema
'array' => $this->createArraySchema($title),
'object' => $this->createObjectSchema($title),
'null' => $this->createNullSchema($title),
- null => $this->createUnionSchema($title), // Handle union types or no type
- default => throw new SchemaException('Unsupported schema type: ' . (is_string($type) ? $type : gettype(
- $type,
- ))),
+ null => $this->createUnionSchema($title),
+ default => throw new SchemaException(
+ 'Unsupported schema type: ' . (is_string($type) ? $type : gettype($type)),
+ ),
};
}
@@ -137,236 +150,352 @@ private function getValue(string $key): mixed
}
/**
- * Get a const value that's properly typed for schema.
+ * Resolve a keyword value that may be a boolean or a subschema object.
*/
- private function getConstValue(string $key): bool|float|int|string|null
+ private function getBoolOrSchema(string $key): bool|JsonSchema|null
{
$value = $this->getValue($key);
- if (is_bool($value) || is_float($value) || is_int($value) || is_string($value) || $value === null) {
+ if (is_bool($value)) {
return $value;
}
+ if (is_array($value)) {
+ return (new self($value, $this->schemaVersion))->convert();
+ }
+
return null;
}
/**
- * Apply shared fields to the schema.
+ * Infer a schema type from present validation keywords when no explicit type is given.
*/
- private function applyId(AbstractSchema $schema): void
+ private function inferTypeFromKeywords(): ?string
{
- if (($id = $this->getString('$id')) !== null) {
- $schema->id($id);
+ $stringKeywords = array_flip(
+ [
+ 'pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType'],
+ );
+
+ if (array_intersect_key($this->data, $stringKeywords) !== []) {
+ return 'string';
}
+
+ foreach (['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'] as $keyword) {
+ if (array_key_exists($keyword, $this->data)) {
+ $value = $this->getValue($keyword);
+
+ return is_int($value) || (is_float($value) && floor($value) === $value) ? 'integer' : 'number';
+ }
+ }
+
+ if (array_key_exists('items', $this->data) || array_key_exists('prefixItems', $this->data)) {
+ return 'array';
+ }
+
+ if (array_key_exists('const', $this->data)) {
+ $const = $this->getValue('const');
+
+ return match (true) {
+ is_string($const) => 'string',
+ is_int($const) => 'integer',
+ is_float($const) => 'number',
+ is_bool($const) => 'boolean',
+ is_array($const) => 'array',
+ $const === null => 'null',
+ default => null,
+ };
+ }
+
+ return null;
}
/**
- * Detect schema version from $schema URI.
+ * Determine whether this schema should omit the type keyword.
*/
- private function detectSchemaVersion(string $schemaUri): SchemaVersion
+ private function shouldUseTypelessSchema(): bool
{
- return match (true) {
- str_contains($schemaUri, 'draft-06') => SchemaVersion::Draft_06,
- str_contains($schemaUri, 'draft-07') => SchemaVersion::Draft_07,
- str_contains($schemaUri, 'draft/2019-09') => SchemaVersion::Draft_2019_09,
- str_contains($schemaUri, 'draft/2020-12') => SchemaVersion::Draft_2020_12,
- default => $this->schemaVersion,
- };
+ if (array_key_exists('type', $this->data)) {
+ return false;
+ }
+
+ $structuralKeywords = array_flip([
+ '$ref', 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else',
+ '$defs', 'definitions', 'properties', 'patternProperties',
+ 'dependentSchemas', 'dependentRequired', 'required',
+ ]);
+
+ return array_intersect_key($this->data, $structuralKeywords) !== [];
}
- private function createStringSchema(?string $title): StringSchema
+ /**
+ * Apply keywords shared across all schema types.
+ */
+ private function applyCommonKeywords(AbstractSchema $schema): void
{
- $stringSchema = new StringSchema($title, $this->schemaVersion);
- $this->applyId($stringSchema);
-
- if (($minLength = $this->getInt('minLength')) !== null) {
- $stringSchema->minLength($minLength);
+ if (($id = $this->getString('$id')) !== null) {
+ $schema->id($id);
}
- if (($maxLength = $this->getInt('maxLength')) !== null) {
- $stringSchema->maxLength($maxLength);
+ if (($anchor = $this->getString('$anchor')) !== null) {
+ $schema->anchor($anchor);
}
- if (($pattern = $this->getString('pattern')) !== null) {
- $stringSchema->pattern($pattern);
+ if (($description = $this->getString('description')) !== null) {
+ $schema->description($description);
}
- if (($contentEncoding = $this->getString('contentEncoding')) !== null) {
- $stringSchema->contentEncoding($contentEncoding);
+ if (($comment = $this->getString('$comment')) !== null) {
+ $schema->comment($comment);
}
- if (($contentMediaType = $this->getString('contentMediaType')) !== null) {
- $stringSchema->contentMediaType($contentMediaType);
+ if (array_key_exists('default', $this->data)) {
+ $schema->default($this->getValue('default'));
}
- $contentSchema = $this->getValue('contentSchema');
+ if ($this->getBool('deprecated')) {
+ $schema->deprecated();
+ }
- if (is_array($contentSchema)) {
- $converter = new self($contentSchema, $this->schemaVersion);
- $stringSchema->contentSchema($converter->convert());
- } elseif (is_bool($contentSchema)) {
- $stringSchema->contentSchema($contentSchema);
+ if ($this->getBool('readOnly')) {
+ $schema->readOnly();
}
- if (($format = $this->getString('format')) !== null) {
- $stringSchema->format($format);
+ if ($this->getBool('writeOnly')) {
+ $schema->writeOnly();
}
if (($enum = $this->getArray('enum')) !== null && $enum !== []) {
/** @var non-empty-array $enum */
- $stringSchema->enum($enum);
- }
-
- if (($const = $this->getConstValue('const')) !== null) {
- $stringSchema->const($const);
+ $schema->enum($enum);
}
- if (($default = $this->getValue('default')) !== null) {
- $stringSchema->default($default);
- }
+ if (array_key_exists('const', $this->data)) {
+ $const = $this->getValue('const');
- if (($description = $this->getString('description')) !== null) {
- $stringSchema->description($description);
+ if (is_bool($const) || is_float($const) || is_int($const) || is_string($const) || $const === null) {
+ $schema->const($const);
+ }
}
- if ($this->getBool('deprecated')) {
- $stringSchema->deprecated();
+ if (($examples = $this->getArray('examples')) !== null) {
+ $schema->examples($examples);
}
- if ($this->getBool('readOnly')) {
- $stringSchema->readOnly();
+ if (($format = $this->getString('format')) !== null) {
+ $schema->format($format);
}
- if ($this->getBool('writeOnly')) {
- $stringSchema->writeOnly();
+ if (($ref = $this->getString('$ref')) !== null) {
+ $schema->ref($ref);
}
- return $stringSchema;
+ $this->applyConditionals($schema);
+ $this->applyDefinitions($schema);
}
- private function createNumberSchema(?string $title): NumberSchema
+ /**
+ * Apply conditional composition keywords.
+ */
+ private function applyConditionals(AbstractSchema $schema): void
{
- $numberSchema = new NumberSchema($title, $this->schemaVersion);
- $this->applyId($numberSchema);
-
- if (($minimum = $this->getFloat('minimum')) !== null) {
- $numberSchema->minimum($minimum);
+ if (($allOf = $this->getArrayOfSchemas('allOf')) !== []) {
+ $schema->allOf(...$allOf);
}
- if (($maximum = $this->getFloat('maximum')) !== null) {
- $numberSchema->maximum($maximum);
+ if (($anyOf = $this->getArrayOfSchemas('anyOf')) !== []) {
+ $schema->anyOf(...$anyOf);
}
- if (($exclusiveMinimum = $this->getFloat('exclusiveMinimum')) !== null) {
- $numberSchema->exclusiveMinimum($exclusiveMinimum);
+ if (($oneOf = $this->getArrayOfSchemas('oneOf')) !== []) {
+ $schema->oneOf(...$oneOf);
}
- if (($exclusiveMaximum = $this->getFloat('exclusiveMaximum')) !== null) {
- $numberSchema->exclusiveMaximum($exclusiveMaximum);
+ if (($not = $this->convertSubschema($this->getValue('not'))) instanceof JsonSchema) {
+ $schema->not($not);
}
- if (($multipleOf = $this->getFloat('multipleOf')) !== null) {
- $numberSchema->multipleOf($multipleOf);
- }
+ if (($if = $this->convertSubschema($this->getValue('if'))) instanceof JsonSchema) {
+ $schema->if($if);
- if (($enum = $this->getArray('enum')) !== null && $enum !== []) {
- /** @var non-empty-array $enum */
- $numberSchema->enum($enum);
- }
+ if (($then = $this->convertSubschema($this->getValue('then'))) instanceof JsonSchema) {
+ $schema->then($then);
+ }
- if (($const = $this->getConstValue('const')) !== null) {
- $numberSchema->const($const);
+ if (($else = $this->convertSubschema($this->getValue('else'))) instanceof JsonSchema) {
+ $schema->else($else);
+ }
}
+ }
- if (($default = $this->getValue('default')) !== null) {
- $numberSchema->default($default);
- }
+ /**
+ * Apply $defs / definitions to the schema.
+ */
+ private function applyDefinitions(AbstractSchema $schema): void
+ {
+ $definitions = $this->getArray('$defs') ?? $this->getArray('definitions');
- if (($description = $this->getString('description')) !== null) {
- $numberSchema->description($description);
+ if ($definitions === null) {
+ return;
}
- return $numberSchema;
+ foreach ($definitions as $name => $definitionData) {
+ if (! is_string($name)) {
+ continue;
+ }
+
+ if (! is_array($definitionData)) {
+ continue;
+ }
+
+ $schema->addDefinition($name, (new self($definitionData, $this->schemaVersion))->convert());
+ }
}
- private function createIntegerSchema(?string $title): IntegerSchema
+ /**
+ * Apply object-specific keywords.
+ */
+ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $objectSchema): void
{
- $integerSchema = new IntegerSchema($title, $this->schemaVersion);
- $this->applyId($integerSchema);
+ $required = $this->getArray('required') ?? [];
- if (($minimum = $this->getInt('minimum')) !== null) {
- $integerSchema->minimum($minimum);
- }
+ if (($properties = $this->getArray('properties')) !== null) {
+ $propertySchemas = [];
+ $requiredProps = [];
- if (($maximum = $this->getInt('maximum')) !== null) {
- $integerSchema->maximum($maximum);
- }
+ foreach ($properties as $name => $propertyData) {
+ if (! is_string($name)) {
+ continue;
+ }
- if (($exclusiveMinimum = $this->getInt('exclusiveMinimum')) !== null) {
- $integerSchema->exclusiveMinimum($exclusiveMinimum);
- }
+ if (! is_array($propertyData)) {
+ continue;
+ }
- if (($exclusiveMaximum = $this->getInt('exclusiveMaximum')) !== null) {
- $integerSchema->exclusiveMaximum($exclusiveMaximum);
- }
+ $propertySchemas[$name] = (new self($propertyData, $this->schemaVersion))->convert();
+
+ if (in_array($name, $required, true)) {
+ $requiredProps[] = $name;
+ }
+ }
+
+ $reflection = new ReflectionClass($objectSchema);
+ $reflection->getProperty('properties')->setValue($objectSchema, $propertySchemas);
+ $reflection->getProperty('requiredProperties')->setValue($objectSchema, $requiredProps);
+ } elseif ($required !== []) {
+ $requiredProps = array_values(array_filter($required, is_string(...)));
- if (($multipleOf = $this->getInt('multipleOf')) !== null) {
- $integerSchema->multipleOf($multipleOf);
+ if ($requiredProps !== []) {
+ $reflection = new ReflectionClass($objectSchema);
+ $reflection->getProperty('requiredProperties')->setValue($objectSchema, $requiredProps);
+ }
}
- if (($enum = $this->getArray('enum')) !== null && $enum !== []) {
- /** @var non-empty-array $enum */
- $integerSchema->enum($enum);
+ if (($patternProperties = $this->getArray('patternProperties')) !== null) {
+ foreach ($patternProperties as $pattern => $propertyData) {
+ if (! is_string($pattern)) {
+ continue;
+ }
+
+ if (! is_array($propertyData)) {
+ continue;
+ }
+
+ $objectSchema->patternProperty($pattern, (new self($propertyData, $this->schemaVersion))->convert());
+ }
}
- if (($const = $this->getConstValue('const')) !== null) {
- $integerSchema->const($const);
+ if (($propertyNames = $this->getArray('propertyNames')) !== null) {
+ $objectSchema->propertyNames((new self($propertyNames, $this->schemaVersion))->convert());
}
- if (($default = $this->getValue('default')) !== null) {
- $integerSchema->default($default);
+ if (($additionalProperties = $this->getBoolOrSchema('additionalProperties')) !== null) {
+ $objectSchema->additionalProperties($additionalProperties);
}
- if (($description = $this->getString('description')) !== null) {
- $integerSchema->description($description);
+ if (($unevaluatedProperties = $this->getBoolOrSchema('unevaluatedProperties')) !== null) {
+ $objectSchema->unevaluatedProperties($unevaluatedProperties);
}
- return $integerSchema;
- }
+ if (($dependentSchemas = $this->getArray('dependentSchemas')) !== null) {
+ foreach ($dependentSchemas as $property => $dependentData) {
+ if (! is_string($property)) {
+ continue;
+ }
- private function createBooleanSchema(?string $title): BooleanSchema
- {
- $booleanSchema = new BooleanSchema($title, $this->schemaVersion);
- $this->applyId($booleanSchema);
+ if (! is_array($dependentData)) {
+ continue;
+ }
- if (($const = $this->getConstValue('const')) !== null) {
- $booleanSchema->const($const);
+ $objectSchema->dependentSchema($property, (new self($dependentData, $this->schemaVersion))->convert());
+ }
}
- if (($default = $this->getValue('default')) !== null) {
- $booleanSchema->default($default);
- }
+ if (($dependentRequired = $this->getArray('dependentRequired')) !== null) {
+ /** @var array> $normalized */
+ $normalized = [];
- if (($description = $this->getString('description')) !== null) {
- $booleanSchema->description($description);
+ foreach ($dependentRequired as $property => $requiredProperties) {
+ if (! is_string($property)) {
+ continue;
+ }
+
+ if (! is_array($requiredProperties)) {
+ continue;
+ }
+
+ $normalized[$property] = array_values(array_filter($requiredProperties, is_string(...)));
+ }
+
+ if ($normalized !== []) {
+ $objectSchema->dependentRequired($normalized);
+ }
}
- if ($this->getBool('readOnly')) {
- $booleanSchema->readOnly();
+ if (($minProperties = $this->getInt('minProperties')) !== null) {
+ $objectSchema->minProperties($minProperties);
}
- return $booleanSchema;
+ if (($maxProperties = $this->getInt('maxProperties')) !== null) {
+ $objectSchema->maxProperties($maxProperties);
+ }
}
- private function createArraySchema(?string $title): ArraySchema
+ /**
+ * Apply array-specific keywords.
+ */
+ private function applyArrayKeywords(ArraySchema $arraySchema): void
{
- $arraySchema = new ArraySchema($title, $this->schemaVersion);
- $this->applyId($arraySchema);
+ $items = $this->getValue('items');
+
+ if (is_array($items)) {
+ if (array_is_list($items)) {
+ $tupleSchemas = array_values(array_map(
+ fn(array $item): JsonSchema => (new self($item, $this->schemaVersion))->convert(),
+ array_filter($items, is_array(...)),
+ ));
+
+ if ($tupleSchemas !== []) {
+ $arraySchema->tupleItems($tupleSchemas);
+ }
+ } else {
+ $arraySchema->items((new self($items, $this->schemaVersion))->convert());
+ }
+ }
- if (($items = $this->getArray('items')) !== null) {
- $converter = new self($items, $this->schemaVersion);
- $itemSchema = $converter->convert();
- $arraySchema->items($itemSchema);
+ if (($additionalItems = $this->getBoolOrSchema('additionalItems')) !== null) {
+ $arraySchema->additionalItems($additionalItems);
+ }
+
+ if (($prefixItems = $this->getArray('prefixItems')) !== null && array_is_list($prefixItems)) {
+ $prefixSchemas = array_values(array_map(
+ fn(array $item): JsonSchema => (new self($item, $this->schemaVersion))->convert(),
+ array_filter($prefixItems, is_array(...)),
+ ));
+
+ if ($prefixSchemas !== []) {
+ $arraySchema->prefixItems($prefixSchemas);
+ }
}
if (($minItems = $this->getInt('minItems')) !== null) {
@@ -382,9 +511,7 @@ private function createArraySchema(?string $title): ArraySchema
}
if (($contains = $this->getArray('contains')) !== null) {
- $converter = new self($contains, $this->schemaVersion);
- $containsSchema = $converter->convert();
- $arraySchema->contains($containsSchema);
+ $arraySchema->contains((new self($contains, $this->schemaVersion))->convert());
}
if (($minContains = $this->getInt('minContains')) !== null) {
@@ -395,121 +522,228 @@ private function createArraySchema(?string $title): ArraySchema
$arraySchema->maxContains($maxContains);
}
- if (($description = $this->getString('description')) !== null) {
- $arraySchema->description($description);
+ if (($unevaluatedItems = $this->getBoolOrSchema('unevaluatedItems')) !== null) {
+ $arraySchema->unevaluatedItems($unevaluatedItems);
}
+ }
- return $arraySchema;
+ /**
+ * Apply numeric constraint keywords.
+ */
+ private function applyNumericKeywords(AbstractSchema $schema): void
+ {
+ if ($schema instanceof IntegerSchema) {
+ if (($minimum = $this->getInt('minimum')) !== null) {
+ $schema->minimum($minimum);
+ }
+
+ if (($maximum = $this->getInt('maximum')) !== null) {
+ $schema->maximum($maximum);
+ }
+
+ if (($exclusiveMinimum = $this->getInt('exclusiveMinimum')) !== null) {
+ $schema->exclusiveMinimum($exclusiveMinimum);
+ }
+
+ if (($exclusiveMaximum = $this->getInt('exclusiveMaximum')) !== null) {
+ $schema->exclusiveMaximum($exclusiveMaximum);
+ }
+
+ if (($multipleOf = $this->getInt('multipleOf')) !== null) {
+ $schema->multipleOf($multipleOf);
+ }
+
+ return;
+ }
+
+ if (! $schema instanceof NumberSchema && ! $schema instanceof UnionSchema && ! $schema instanceof TypelessSchema) {
+ return;
+ }
+
+ if (($minimum = $this->getFloat('minimum')) !== null) {
+ $schema->minimum($minimum);
+ }
+
+ if (($maximum = $this->getFloat('maximum')) !== null) {
+ $schema->maximum($maximum);
+ }
+
+ if (($exclusiveMinimum = $this->getFloat('exclusiveMinimum')) !== null) {
+ $schema->exclusiveMinimum($exclusiveMinimum);
+ }
+
+ if (($exclusiveMaximum = $this->getFloat('exclusiveMaximum')) !== null) {
+ $schema->exclusiveMaximum($exclusiveMaximum);
+ }
+
+ if (($multipleOf = $this->getFloat('multipleOf')) !== null) {
+ $schema->multipleOf($multipleOf);
+ }
}
- private function createObjectSchema(?string $title): ObjectSchema
+ /**
+ * Detect schema version from a $schema URI.
+ */
+ private function detectSchemaVersion(string $schemaUri): SchemaVersion
{
- $objectSchema = new ObjectSchema($title, $this->schemaVersion);
- $this->applyId($objectSchema);
- $required = $this->getArray('required') ?? [];
+ return match (true) {
+ str_contains($schemaUri, 'draft-06') => SchemaVersion::Draft_06,
+ str_contains($schemaUri, 'draft-07') => SchemaVersion::Draft_07,
+ str_contains($schemaUri, 'draft/2019-09') => SchemaVersion::Draft_2019_09,
+ str_contains($schemaUri, 'draft/2020-12') => SchemaVersion::Draft_2020_12,
+ default => $this->schemaVersion,
+ };
+ }
- if (($properties = $this->getArray('properties')) !== null) {
- $propertySchemas = [];
- $requiredProps = [];
+ /**
+ * Convert a raw value to a JsonSchema instance if it is an array subschema.
+ */
+ private function convertSubschema(mixed $value): ?JsonSchema
+ {
+ if (! is_array($value)) {
+ return null;
+ }
- foreach ($properties as $name => $propertyData) {
- // Runtime validation needed for type safety
- if (! is_array($propertyData)) {
- continue;
- }
+ return (new self($value, $this->schemaVersion))->convert();
+ }
+
+ /**
+ * Convert an array of raw subschema objects to JsonSchema instances.
+ *
+ * @return array
+ */
+ private function getArrayOfSchemas(string $key): array
+ {
+ $value = $this->getArray($key);
- $converter = new self($propertyData, $this->schemaVersion);
- $propertySchema = $converter->convert();
+ if ($value === null || ! array_is_list($value)) {
+ return [];
+ }
- $propertySchemas[$name] = $propertySchema;
+ return array_values(array_map(
+ fn(array $item): JsonSchema => (new self($item, $this->schemaVersion))->convert(),
+ array_filter($value, is_array(...)),
+ ));
+ }
- // Track required properties
- if (in_array($name, $required, true)) {
- $requiredProps[] = $name;
- }
- }
+ private function createTypelessSchema(?string $title): TypelessSchema
+ {
+ $typelessSchema = new TypelessSchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($typelessSchema);
- // Set properties and required directly using reflection to avoid title requirement
- $reflectionClass = new ReflectionClass($objectSchema);
- $propertiesProperty = $reflectionClass->getProperty('properties');
- $propertiesProperty->setValue($objectSchema, $propertySchemas);
+ $objectKeywords = array_flip(['properties', 'patternProperties', 'required']);
- $requiredProperty = $reflectionClass->getProperty('requiredProperties');
- $requiredProperty->setValue($objectSchema, $requiredProps);
+ if (array_intersect_key($this->data, $objectKeywords) !== []) {
+ $this->applyObjectKeywords($typelessSchema);
}
- $additionalProperties = $this->getValue('additionalProperties');
+ return $typelessSchema;
+ }
- if ($additionalProperties !== null) {
- if (is_bool($additionalProperties)) {
- $objectSchema->additionalProperties($additionalProperties);
- } elseif (is_array($additionalProperties)) {
- $converter = new self($additionalProperties, $this->schemaVersion);
- $additionalSchema = $converter->convert();
- $objectSchema->additionalProperties($additionalSchema);
- }
+ private function createStringSchema(?string $title): StringSchema
+ {
+ $stringSchema = new StringSchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($stringSchema);
+
+ if (($minLength = $this->getInt('minLength')) !== null) {
+ $stringSchema->minLength($minLength);
}
- if (($minProperties = $this->getInt('minProperties')) !== null) {
- $objectSchema->minProperties($minProperties);
+ if (($maxLength = $this->getInt('maxLength')) !== null) {
+ $stringSchema->maxLength($maxLength);
}
- if (($maxProperties = $this->getInt('maxProperties')) !== null) {
- $objectSchema->maxProperties($maxProperties);
+ if (($pattern = $this->getString('pattern')) !== null) {
+ $stringSchema->pattern($pattern);
}
- if (($description = $this->getString('description')) !== null) {
- $objectSchema->description($description);
+ if (($contentEncoding = $this->getString('contentEncoding')) !== null) {
+ $stringSchema->contentEncoding($contentEncoding);
}
+ if (($contentMediaType = $this->getString('contentMediaType')) !== null) {
+ $stringSchema->contentMediaType($contentMediaType);
+ }
+
+ $contentSchema = $this->getValue('contentSchema');
+
+ if (is_array($contentSchema)) {
+ $stringSchema->contentSchema((new self($contentSchema, $this->schemaVersion))->convert());
+ } elseif (is_bool($contentSchema)) {
+ $stringSchema->contentSchema($contentSchema);
+ }
+
+ return $stringSchema;
+ }
+
+ private function createNumberSchema(?string $title): NumberSchema
+ {
+ $numberSchema = new NumberSchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($numberSchema);
+ $this->applyNumericKeywords($numberSchema);
+
+ return $numberSchema;
+ }
+
+ private function createIntegerSchema(?string $title): IntegerSchema
+ {
+ $integerSchema = new IntegerSchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($integerSchema);
+ $this->applyNumericKeywords($integerSchema);
+
+ return $integerSchema;
+ }
+
+ private function createBooleanSchema(?string $title): BooleanSchema
+ {
+ $booleanSchema = new BooleanSchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($booleanSchema);
+
+ return $booleanSchema;
+ }
+
+ private function createArraySchema(?string $title): ArraySchema
+ {
+ $arraySchema = new ArraySchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($arraySchema);
+ $this->applyArrayKeywords($arraySchema);
+
+ return $arraySchema;
+ }
+
+ private function createObjectSchema(?string $title): ObjectSchema
+ {
+ $objectSchema = new ObjectSchema($title, $this->schemaVersion);
+ $this->applyCommonKeywords($objectSchema);
+ $this->applyObjectKeywords($objectSchema);
+
return $objectSchema;
}
private function createNullSchema(?string $title): NullSchema
{
$nullSchema = new NullSchema($title, $this->schemaVersion);
- $this->applyId($nullSchema);
-
- if (($description = $this->getString('description')) !== null) {
- $nullSchema->description($description);
- }
+ $this->applyCommonKeywords($nullSchema);
return $nullSchema;
}
private function createUnionSchema(?string $title): UnionSchema
{
- // Handle union types when type is an array
$typeData = $this->getValue('type');
if (is_array($typeData)) {
- $types = [];
- foreach ($typeData as $typeName) {
- if (is_string($typeName)) {
- $types[] = SchemaType::from($typeName);
- }
- }
-
+ $types = array_values(array_map(
+ SchemaType::from(...),
+ array_filter($typeData, is_string(...)),
+ ));
$schema = new UnionSchema($types, $title, $this->schemaVersion);
} else {
- // If no type is specified, treat as mixed
$schema = new UnionSchema(SchemaType::cases(), $title, $this->schemaVersion);
}
- $this->applyId($schema);
-
- if (($enum = $this->getArray('enum')) !== null && $enum !== []) {
- /** @var non-empty-array $enum */
- $schema->enum($enum);
- }
-
- if (($const = $this->getConstValue('const')) !== null) {
- $schema->const($const);
- }
-
- if (($description = $this->getString('description')) !== null) {
- $schema->description($description);
- }
+ $this->applyCommonKeywords($schema);
+ $this->applyNumericKeywords($schema);
return $schema;
}
diff --git a/src/Schema.php b/src/Schema.php
index 2aadb94..5a8fefc 100644
--- a/src/Schema.php
+++ b/src/Schema.php
@@ -17,6 +17,7 @@
use Cortex\JsonSchema\Types\BooleanSchema;
use Cortex\JsonSchema\Types\IntegerSchema;
use Cortex\JsonSchema\Contracts\JsonSchema;
+use Cortex\JsonSchema\Types\TypelessSchema;
use Cortex\JsonSchema\Converters\EnumConverter;
use Cortex\JsonSchema\Converters\JsonConverter;
use Cortex\JsonSchema\Converters\ClassConverter;
@@ -99,6 +100,14 @@ public static function mixed(?string $title = null, ?SchemaVersion $schemaVersio
return new UnionSchema(SchemaType::cases(), $title, $schemaVersion ?? self::getDefaultVersion());
}
+ /**
+ * Create a typeless schema for composition-only or definition-only documents.
+ */
+ public static function typeless(?string $title = null, ?SchemaVersion $schemaVersion = null): TypelessSchema
+ {
+ return new TypelessSchema($title, $schemaVersion ?? self::getDefaultVersion());
+ }
+
/**
* Create a schema from a given closure.
*/
@@ -188,4 +197,14 @@ public static function fromJson(string|array $json, ?SchemaVersion $schemaVersio
{
return (new JsonConverter($json, $schemaVersion ?? self::getDefaultVersion()))->convert();
}
+
+ /**
+ * Create a schema from a given array.
+ *
+ * @param array $array
+ */
+ public static function fromArray(array $array, ?SchemaVersion $schemaVersion = null): JsonSchema
+ {
+ return self::fromJson($array, $schemaVersion ?? self::getDefaultVersion());
+ }
}
diff --git a/src/Types/AbstractSchema.php b/src/Types/AbstractSchema.php
index 8e5abca..92dfdc4 100644
--- a/src/Types/AbstractSchema.php
+++ b/src/Types/AbstractSchema.php
@@ -12,6 +12,7 @@
use Cortex\JsonSchema\Types\Concerns\HasEnum;
use Cortex\JsonSchema\Types\Concerns\HasConst;
use Cortex\JsonSchema\Types\Concerns\HasTitle;
+use Cortex\JsonSchema\Types\Concerns\HasAnchor;
use Cortex\JsonSchema\Types\Concerns\HasFormat;
use Cortex\JsonSchema\Types\Concerns\HasMetadata;
use Cortex\JsonSchema\Types\Concerns\HasRequired;
@@ -26,6 +27,7 @@
abstract class AbstractSchema implements JsonSchema
{
use HasRef;
+ use HasAnchor;
use HasId;
use HasEnum;
use HasConst;
@@ -44,10 +46,10 @@ abstract class AbstractSchema implements JsonSchema
protected SchemaVersion $schemaVersion = SchemaVersion::Draft_2020_12;
/**
- * @param \Cortex\JsonSchema\Enums\SchemaType|array $type
+ * @param \Cortex\JsonSchema\Enums\SchemaType|array|null $type
*/
public function __construct(
- protected SchemaType|array $type,
+ protected SchemaType|array|null $type,
?string $title = null,
?SchemaVersion $schemaVersion = null,
) {
@@ -79,7 +81,7 @@ public function getVersion(): SchemaVersion
*/
public function nullable(): static
{
- if ($this->isNullable()) {
+ if ($this->type === null || $this->isNullable()) {
return $this;
}
@@ -105,17 +107,20 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true
// Validate that all features used by this schema are supported by the version
$this->validateAllUsedFeatures();
- $schema = [
- 'type' => is_array($this->type)
+ $schema = [];
+
+ if ($this->type !== null) {
+ $schema['type'] = is_array($this->type)
? array_map(static fn(SchemaType $schemaType) => $schemaType->value, $this->type)
- : $this->type->value,
- ];
+ : $this->type->value;
+ }
if ($includeSchemaRef) {
$schema['$schema'] = $this->schemaVersion->value;
}
$schema = $this->addIdToSchema($schema);
+ $schema = $this->addAnchorToSchema($schema);
$schema = $this->addTitleToSchema($schema, $includeTitle);
$schema = $this->addFormatToSchema($schema);
$schema = $this->addDescriptionToSchema($schema);
@@ -158,6 +163,7 @@ protected function getUsedFeatures(): array
...$this->getMetadataFeatures(),
...$this->getReadWriteFeatures(),
...$this->getFormatFeatures(),
+ ...$this->getAnchorFeatures(),
];
// Remove duplicates by using feature values as keys
diff --git a/src/Types/Concerns/HasAnchor.php b/src/Types/Concerns/HasAnchor.php
new file mode 100644
index 0000000..ab3e919
--- /dev/null
+++ b/src/Types/Concerns/HasAnchor.php
@@ -0,0 +1,57 @@
+validateFeatureSupport(SchemaFeature::Anchor);
+
+ $this->anchor = $anchor;
+
+ return $this;
+ }
+
+ /**
+ * Add $anchor to schema array.
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addAnchorToSchema(array $schema): array
+ {
+ if ($this->anchor !== null) {
+ $schema['$anchor'] = $this->anchor;
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Get anchor features used by this schema.
+ *
+ * @return array<\Cortex\JsonSchema\Enums\SchemaFeature>
+ */
+ protected function getAnchorFeatures(): array
+ {
+ if ($this->anchor === null) {
+ return [];
+ }
+
+ return [SchemaFeature::Anchor];
+ }
+}
diff --git a/src/Types/Concerns/HasConditionals.php b/src/Types/Concerns/HasConditionals.php
index f184eae..8fdcea9 100644
--- a/src/Types/Concerns/HasConditionals.php
+++ b/src/Types/Concerns/HasConditionals.php
@@ -8,7 +8,9 @@
use Cortex\JsonSchema\Contracts\JsonSchema;
use Cortex\JsonSchema\Exceptions\SchemaException;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasConditionals
{
protected ?JsonSchema $if = null;
diff --git a/src/Types/Concerns/HasConst.php b/src/Types/Concerns/HasConst.php
index 718991e..bdf0336 100644
--- a/src/Types/Concerns/HasConst.php
+++ b/src/Types/Concerns/HasConst.php
@@ -4,7 +4,9 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasConst
{
/**
diff --git a/src/Types/Concerns/HasDescription.php b/src/Types/Concerns/HasDescription.php
index f31791c..7468f83 100644
--- a/src/Types/Concerns/HasDescription.php
+++ b/src/Types/Concerns/HasDescription.php
@@ -4,7 +4,9 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasDescription
{
protected ?string $description = null;
diff --git a/src/Types/Concerns/HasEnum.php b/src/Types/Concerns/HasEnum.php
index 41a7cff..28cdbff 100644
--- a/src/Types/Concerns/HasEnum.php
+++ b/src/Types/Concerns/HasEnum.php
@@ -4,22 +4,33 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasEnum
{
/**
- * @var non-empty-array|null
+ * @var non-empty-array|null
*/
protected ?array $enum = null;
/**
* Set the allowed enum values.
*
- * @param non-empty-array $values
+ * @param non-empty-array $values
*/
public function enum(array $values): static
{
- $this->enum = array_values(array_unique($values, SORT_REGULAR));
+ $unique = [];
+
+ foreach ($values as $value) {
+ if (! in_array($value, $unique, strict: true)) {
+ $unique[] = $value;
+ }
+ }
+
+ /** @var non-empty-array $unique */
+ $this->enum = $unique;
return $this;
}
diff --git a/src/Types/Concerns/HasFormat.php b/src/Types/Concerns/HasFormat.php
index 844bf61..e7d6a73 100644
--- a/src/Types/Concerns/HasFormat.php
+++ b/src/Types/Concerns/HasFormat.php
@@ -7,7 +7,9 @@
use Cortex\JsonSchema\Enums\SchemaFormat;
use Cortex\JsonSchema\Enums\SchemaFeature;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasFormat
{
protected SchemaFormat|string|null $format = null;
diff --git a/src/Types/Concerns/HasId.php b/src/Types/Concerns/HasId.php
index 7cc969e..373999f 100644
--- a/src/Types/Concerns/HasId.php
+++ b/src/Types/Concerns/HasId.php
@@ -4,7 +4,9 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasId
{
protected ?string $id = null;
diff --git a/src/Types/Concerns/HasInitialTitle.php b/src/Types/Concerns/HasInitialTitle.php
index 7f2204c..cc46e0a 100644
--- a/src/Types/Concerns/HasInitialTitle.php
+++ b/src/Types/Concerns/HasInitialTitle.php
@@ -4,7 +4,9 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasInitialTitle
{
protected ?string $initialTitle = null;
diff --git a/src/Types/Concerns/HasItems.php b/src/Types/Concerns/HasItems.php
index ea5d98a..b687021 100644
--- a/src/Types/Concerns/HasItems.php
+++ b/src/Types/Concerns/HasItems.php
@@ -7,7 +7,9 @@
use Cortex\JsonSchema\Contracts\JsonSchema;
use Cortex\JsonSchema\Exceptions\SchemaException;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasItems
{
protected ?JsonSchema $items = null;
@@ -18,6 +20,13 @@ trait HasItems
protected bool $uniqueItems = false;
+ /**
+ * @var array
+ */
+ protected array $tupleItems = [];
+
+ protected JsonSchema|bool|null $additionalItems = null;
+
/**
* Set the schema for validating array items.
*/
@@ -28,6 +37,28 @@ public function items(JsonSchema $jsonSchema): static
return $this;
}
+ /**
+ * Set tuple item schemas for draft-07 style array validation.
+ *
+ * @param array $schemas
+ */
+ public function tupleItems(array $schemas): static
+ {
+ $this->tupleItems = array_values($schemas);
+
+ return $this;
+ }
+
+ /**
+ * Set whether additional tuple items are allowed and optionally their schema.
+ */
+ public function additionalItems(bool|JsonSchema $allowed): static
+ {
+ $this->additionalItems = $allowed;
+
+ return $this;
+ }
+
/**
* Set the minimum number of items.
*
@@ -83,10 +114,24 @@ public function uniqueItems(bool $value = true): static
*/
protected function addItemsToSchema(array $schema): array
{
- if ($this->items !== null) {
+ if ($this->tupleItems !== []) {
+ $schema['items'] = array_map(
+ static fn(JsonSchema $jsonSchema): array => $jsonSchema->toArray(
+ includeSchemaRef: false,
+ includeTitle: false,
+ ),
+ $this->tupleItems,
+ );
+ } elseif ($this->items !== null) {
$schema['items'] = $this->items->toArray();
}
+ if ($this->additionalItems !== null) {
+ $schema['additionalItems'] = $this->additionalItems instanceof JsonSchema
+ ? $this->additionalItems->toArray(includeSchemaRef: false, includeTitle: false)
+ : $this->additionalItems;
+ }
+
if ($this->minItems !== null) {
$schema['minItems'] = $this->minItems;
}
diff --git a/src/Types/Concerns/HasMetadata.php b/src/Types/Concerns/HasMetadata.php
index 80ae5ef..8e1eb38 100644
--- a/src/Types/Concerns/HasMetadata.php
+++ b/src/Types/Concerns/HasMetadata.php
@@ -6,7 +6,9 @@
use Cortex\JsonSchema\Enums\SchemaFeature;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasMetadata
{
protected mixed $default = null;
diff --git a/src/Types/Concerns/HasNumericConstraints.php b/src/Types/Concerns/HasNumericConstraints.php
index 956001c..93c1d8c 100644
--- a/src/Types/Concerns/HasNumericConstraints.php
+++ b/src/Types/Concerns/HasNumericConstraints.php
@@ -6,7 +6,9 @@
use Cortex\JsonSchema\Exceptions\SchemaException;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasNumericConstraints
{
protected ?float $minimum = null;
diff --git a/src/Types/Concerns/HasProperties.php b/src/Types/Concerns/HasProperties.php
index b80b53d..edea723 100644
--- a/src/Types/Concerns/HasProperties.php
+++ b/src/Types/Concerns/HasProperties.php
@@ -8,7 +8,9 @@
use Cortex\JsonSchema\Contracts\JsonSchema;
use Cortex\JsonSchema\Exceptions\SchemaException;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasProperties
{
/**
@@ -47,6 +49,11 @@ trait HasProperties
*/
protected array $dependentSchemas = [];
+ /**
+ * @var array>
+ */
+ protected array $dependentRequired = [];
+
/**
* Set properties.
*
@@ -132,6 +139,23 @@ public function dependentSchemas(array $schemas): static
return $this;
}
+ /**
+ * Set dependent required property names.
+ * This feature is only available in Draft 2019-09 and later.
+ *
+ * @param array> $dependencies
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function dependentRequired(array $dependencies): static
+ {
+ $this->validateFeatureSupport(SchemaFeature::DependentRequired);
+
+ $this->dependentRequired = $dependencies;
+
+ return $this;
+ }
+
/**
* Set the minimum number of properties
*
@@ -336,6 +360,10 @@ protected function addPropertiesToSchema(array $schema): array
}
}
+ if ($this->dependentRequired !== []) {
+ $schema['dependentRequired'] = $this->dependentRequired;
+ }
+
return $schema;
}
@@ -374,4 +402,18 @@ protected function getDependentSchemasFeatures(): array
return [SchemaFeature::DependentSchemas];
}
+
+ /**
+ * Get dependent required features used by this schema.
+ *
+ * @return array<\Cortex\JsonSchema\Enums\SchemaFeature>
+ */
+ protected function getDependentRequiredFeatures(): array
+ {
+ if ($this->dependentRequired === []) {
+ return [];
+ }
+
+ return [SchemaFeature::DependentRequired];
+ }
}
diff --git a/src/Types/Concerns/HasReadWrite.php b/src/Types/Concerns/HasReadWrite.php
index c95b692..200b746 100644
--- a/src/Types/Concerns/HasReadWrite.php
+++ b/src/Types/Concerns/HasReadWrite.php
@@ -6,7 +6,9 @@
use Cortex\JsonSchema\Enums\SchemaFeature;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasReadWrite
{
protected ?bool $readOnly = null;
diff --git a/src/Types/Concerns/HasRequired.php b/src/Types/Concerns/HasRequired.php
index df8be8c..331aeb7 100644
--- a/src/Types/Concerns/HasRequired.php
+++ b/src/Types/Concerns/HasRequired.php
@@ -4,7 +4,9 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasRequired
{
protected bool $required = false;
diff --git a/src/Types/Concerns/HasTitle.php b/src/Types/Concerns/HasTitle.php
index 55f13ce..087c78b 100644
--- a/src/Types/Concerns/HasTitle.php
+++ b/src/Types/Concerns/HasTitle.php
@@ -4,7 +4,9 @@
namespace Cortex\JsonSchema\Types\Concerns;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasTitle
{
protected ?string $title = null;
diff --git a/src/Types/Concerns/HasValidation.php b/src/Types/Concerns/HasValidation.php
index 05ee480..25bacb6 100644
--- a/src/Types/Concerns/HasValidation.php
+++ b/src/Types/Concerns/HasValidation.php
@@ -11,7 +11,9 @@
use Cortex\JsonSchema\Exceptions\SchemaException;
use Opis\JsonSchema\Exceptions\SchemaException as OpisSchemaException;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait HasValidation
{
/**
diff --git a/src/Types/Concerns/ValidatesVersionFeatures.php b/src/Types/Concerns/ValidatesVersionFeatures.php
index e9c132f..8b08e48 100644
--- a/src/Types/Concerns/ValidatesVersionFeatures.php
+++ b/src/Types/Concerns/ValidatesVersionFeatures.php
@@ -7,7 +7,9 @@
use Cortex\JsonSchema\Enums\SchemaFeature;
use Cortex\JsonSchema\Exceptions\SchemaException;
-/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */
+/**
+ * @mixin \Cortex\JsonSchema\Contracts\JsonSchema
+ */
trait ValidatesVersionFeatures
{
/**
diff --git a/src/Types/ObjectSchema.php b/src/Types/ObjectSchema.php
index 4f5c36e..8652eb4 100644
--- a/src/Types/ObjectSchema.php
+++ b/src/Types/ObjectSchema.php
@@ -43,6 +43,7 @@ protected function getUsedFeatures(): array
...parent::getUsedFeatures(),
...$this->getUnevaluatedPropertiesFeatures(),
...$this->getDependentSchemasFeatures(),
+ ...$this->getDependentRequiredFeatures(),
];
// Remove duplicates by using feature values as keys
diff --git a/src/Types/TypelessSchema.php b/src/Types/TypelessSchema.php
new file mode 100644
index 0000000..5ba4388
--- /dev/null
+++ b/src/Types/TypelessSchema.php
@@ -0,0 +1,39 @@
+
+ */
+ #[Override]
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array
+ {
+ $schema = parent::toArray($includeSchemaRef, $includeTitle);
+
+ $schema = $this->addNumericConstraintsToSchema($schema);
+ $schema = $this->addItemsToSchema($schema);
+
+ return $this->addPropertiesToSchema($schema);
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/address.json b/tests/Fixtures/json-schema-org/examples/address.json
new file mode 100644
index 0000000..6dde69a
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/address.json
@@ -0,0 +1,38 @@
+{
+ "$id": "https://example.com/address.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "An address similar to http://microformats.org/wiki/h-card",
+ "type": "object",
+ "properties": {
+ "postOfficeBox": {
+ "type": "string"
+ },
+ "extendedAddress": {
+ "type": "string"
+ },
+ "streetAddress": {
+ "type": "string"
+ },
+ "locality": {
+ "type": "string"
+ },
+ "region": {
+ "type": "string"
+ },
+ "postalCode": {
+ "type": "string"
+ },
+ "countryName": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "locality",
+ "region",
+ "countryName"
+ ],
+ "dependentRequired": {
+ "postOfficeBox": ["streetAddress"],
+ "extendedAddress": ["streetAddress"]
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/blog-post.json b/tests/Fixtures/json-schema-org/examples/blog-post.json
new file mode 100644
index 0000000..555add0
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/blog-post.json
@@ -0,0 +1,43 @@
+{
+ "$id": "https://example.com/blog-post.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "A representation of a blog post",
+ "type": "object",
+ "required": ["title", "content", "author"],
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ },
+ "publishedDate": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "author": {
+ "$ref": "#/$defs/userProfile"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "$defs": {
+ "userProfile": {
+ "type": "object",
+ "required": ["username", "email"],
+ "properties": {
+ "username": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string",
+ "format": "email"
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/calendar.json b/tests/Fixtures/json-schema-org/examples/calendar.json
new file mode 100644
index 0000000..59d1ef4
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/calendar.json
@@ -0,0 +1,67 @@
+{
+ "$id": "https://example.com/calendar.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "A representation of an event",
+ "type": "object",
+ "required": ["startDate", "summary"],
+ "properties": {
+ "startDate": {
+ "type": "string",
+ "description": "Event starting time"
+ },
+ "endDate": {
+ "type": "string",
+ "description": "Event ending time"
+ },
+ "summary": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "duration": {
+ "type": "string",
+ "description": "Event duration"
+ },
+ "recurrenceDate": {
+ "type": "string",
+ "description": "Recurrence date"
+ },
+ "recurrenceRule": {
+ "type": "string",
+ "description": "Recurrence rule"
+ },
+ "category": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "geo": {
+ "$ref": "#/$defs/geographicalLocation"
+ }
+ },
+ "$defs": {
+ "geographicalLocation": {
+ "title": "Longitude and Latitude Values",
+ "description": "A geographical coordinate.",
+ "required": ["latitude", "longitude"],
+ "type": "object",
+ "properties": {
+ "latitude": {
+ "type": "number",
+ "minimum": -90,
+ "maximum": 90
+ },
+ "longitude": {
+ "type": "number",
+ "minimum": -180,
+ "maximum": 180
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/device.json b/tests/Fixtures/json-schema-org/examples/device.json
new file mode 100644
index 0000000..dbda654
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/device.json
@@ -0,0 +1,54 @@
+{
+ "$id": "https://example.com/device.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "deviceType": {
+ "type": "string"
+ }
+ },
+ "required": ["deviceType"],
+ "oneOf": [
+ {
+ "properties": {
+ "deviceType": {
+ "const": "smartphone"
+ }
+ },
+ "required": ["brand", "model", "screenSize"],
+ "properties": {
+ "brand": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "screenSize": {
+ "type": "number"
+ }
+ }
+ },
+ {
+ "properties": {
+ "deviceType": {
+ "const": "laptop"
+ }
+ },
+ "required": ["brand", "model", "processor", "ramSize"],
+ "properties": {
+ "brand": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "processor": {
+ "type": "string"
+ },
+ "ramSize": {
+ "type": "number"
+ }
+ }
+ }
+ ]
+}
diff --git a/tests/Fixtures/json-schema-org/examples/ecommerce.json b/tests/Fixtures/json-schema-org/examples/ecommerce.json
new file mode 100644
index 0000000..d713774
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/ecommerce.json
@@ -0,0 +1,34 @@
+{
+ "$id": "https://example.com/ecommerce.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$defs": {
+ "product": {
+ "$anchor": "ProductSchema",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "price": {
+ "type": "number",
+ "minimum": 0
+ }
+ }
+ },
+ "order": {
+ "$anchor": "OrderSchema",
+ "type": "object",
+ "properties": {
+ "orderId": {
+ "type": "string"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#ProductSchema"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/geographical-location.json b/tests/Fixtures/json-schema-org/examples/geographical-location.json
new file mode 100644
index 0000000..6312440
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/geographical-location.json
@@ -0,0 +1,20 @@
+{
+ "$id": "https://example.com/geographical-location.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Longitude and Latitude Values",
+ "description": "A geographical coordinate.",
+ "required": ["latitude", "longitude"],
+ "type": "object",
+ "properties": {
+ "latitude": {
+ "type": "number",
+ "minimum": -90,
+ "maximum": 90
+ },
+ "longitude": {
+ "type": "number",
+ "minimum": -180,
+ "maximum": 180
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/health-record.json b/tests/Fixtures/json-schema-org/examples/health-record.json
new file mode 100644
index 0000000..e8530d5
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/health-record.json
@@ -0,0 +1,55 @@
+{
+ "$id": "https://example.com/health-record.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "Schema for representing a health record",
+ "type": "object",
+ "required": ["patientName", "dateOfBirth", "bloodType"],
+ "properties": {
+ "patientName": {
+ "type": "string"
+ },
+ "dateOfBirth": {
+ "type": "string",
+ "format": "date"
+ },
+ "bloodType": {
+ "type": "string"
+ },
+ "allergies": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "conditions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "medications": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "emergencyContact": {
+ "$ref": "#/$defs/userProfile"
+ }
+ },
+ "$defs": {
+ "userProfile": {
+ "type": "object",
+ "required": ["username", "email"],
+ "properties": {
+ "username": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string",
+ "format": "email"
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/job-posting.json b/tests/Fixtures/json-schema-org/examples/job-posting.json
new file mode 100644
index 0000000..638c56e
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/job-posting.json
@@ -0,0 +1,32 @@
+{
+ "$id": "https://example.com/job-posting.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "A representation of a job posting",
+ "type": "object",
+ "required": ["title", "company", "location", "description"],
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "company": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "employmentType": {
+ "type": "string"
+ },
+ "salary": {
+ "type": "number",
+ "minimum": 0
+ },
+ "applicationDeadline": {
+ "type": "string",
+ "format": "date"
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/movie.json b/tests/Fixtures/json-schema-org/examples/movie.json
new file mode 100644
index 0000000..03b1060
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/movie.json
@@ -0,0 +1,32 @@
+{
+ "$id": "https://example.com/movie.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "A representation of a movie",
+ "type": "object",
+ "required": ["title", "director", "releaseDate"],
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "director": {
+ "type": "string"
+ },
+ "releaseDate": {
+ "type": "string",
+ "format": "date"
+ },
+ "genre": {
+ "type": "string",
+ "enum": ["Action", "Comedy", "Drama", "Science Fiction"]
+ },
+ "duration": {
+ "type": "string"
+ },
+ "cast": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/examples/user-profile.json b/tests/Fixtures/json-schema-org/examples/user-profile.json
new file mode 100644
index 0000000..6385698
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/examples/user-profile.json
@@ -0,0 +1,32 @@
+{
+ "$id": "https://example.com/user-profile.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "A representation of a user profile",
+ "type": "object",
+ "required": ["username", "email"],
+ "properties": {
+ "username": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string",
+ "format": "email"
+ },
+ "fullName": {
+ "type": "string"
+ },
+ "age": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "location": {
+ "type": "string"
+ },
+ "interests": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/arrays.json b/tests/Fixtures/json-schema-org/misc/arrays.json
new file mode 100644
index 0000000..03b2029
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/arrays.json
@@ -0,0 +1,40 @@
+{
+ "$id": "https://example.com/arrays.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "description": "Arrays of strings and objects",
+ "title": "Arrays",
+ "type": "object",
+ "properties": {
+ "fruits": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "vegetables": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/veggie"
+ }
+ }
+ },
+ "$defs": {
+ "veggie": {
+ "type": "object",
+ "required": [
+ "veggieName",
+ "veggieLike"
+ ],
+ "properties": {
+ "veggieName": {
+ "type": "string",
+ "description": "The name of the vegetable."
+ },
+ "veggieLike": {
+ "type": "boolean",
+ "description": "Do I like this vegetable?"
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/basic-person.json b/tests/Fixtures/json-schema-org/misc/basic-person.json
new file mode 100644
index 0000000..28a89fe
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/basic-person.json
@@ -0,0 +1,21 @@
+{
+ "$id": "https://example.com/person.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Person",
+ "type": "object",
+ "properties": {
+ "firstName": {
+ "type": "string",
+ "description": "The person's first name."
+ },
+ "lastName": {
+ "type": "string",
+ "description": "The person's last name."
+ },
+ "age": {
+ "description": "Age in years which must be equal to or greater than zero.",
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/complex-object.json b/tests/Fixtures/json-schema-org/misc/complex-object.json
new file mode 100644
index 0000000..29e350c
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/complex-object.json
@@ -0,0 +1,41 @@
+{
+ "$id": "https://example.com/complex-object.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Complex Object",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "age": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "address": {
+ "type": "object",
+ "properties": {
+ "street": {
+ "type": "string"
+ },
+ "city": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string"
+ },
+ "postalCode": {
+ "type": "string",
+ "pattern": "\\d{5}"
+ }
+ },
+ "required": ["street", "city", "state", "postalCode"]
+ },
+ "hobbies": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": ["name", "age"]
+}
diff --git a/tests/Fixtures/json-schema-org/misc/dependent-required.json b/tests/Fixtures/json-schema-org/misc/dependent-required.json
new file mode 100644
index 0000000..4b6d3fb
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/dependent-required.json
@@ -0,0 +1,17 @@
+{
+ "$id": "https://example.com/conditional-validation-dependentRequired.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Conditional Validation with dependentRequired",
+ "type": "object",
+ "properties": {
+ "foo": {
+ "type": "boolean"
+ },
+ "bar": {
+ "type": "string"
+ }
+ },
+ "dependentRequired": {
+ "foo": ["bar"]
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/dependent-schemas.json b/tests/Fixtures/json-schema-org/misc/dependent-schemas.json
new file mode 100644
index 0000000..37c60ae
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/dependent-schemas.json
@@ -0,0 +1,25 @@
+{
+ "$id": "https://example.com/conditional-validation-dependentSchemas.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Conditional Validation with dependentSchemas",
+ "type": "object",
+ "properties": {
+ "foo": {
+ "type": "boolean"
+ },
+ "propertiesCount": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "dependentSchemas": {
+ "foo": {
+ "required": ["propertiesCount"],
+ "properties": {
+ "propertiesCount": {
+ "minimum": 7
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/enumerated-values.json b/tests/Fixtures/json-schema-org/misc/enumerated-values.json
new file mode 100644
index 0000000..93ee2ac
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/enumerated-values.json
@@ -0,0 +1,11 @@
+{
+ "$id": "https://example.com/enumerated-values.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Enumerated Values",
+ "type": "object",
+ "properties": {
+ "data": {
+ "enum": [42, true, "hello", null, [1, 2, 3]]
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/if-else.json b/tests/Fixtures/json-schema-org/misc/if-else.json
new file mode 100644
index 0000000..4d6d254
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/if-else.json
@@ -0,0 +1,39 @@
+{
+ "$id": "https://example.com/conditional-validation-if-else.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Conditional Validation with If-Else",
+ "type": "object",
+ "properties": {
+ "isMember": {
+ "type": "boolean"
+ },
+ "membershipNumber": {
+ "type": "string"
+ }
+ },
+ "required": ["isMember"],
+ "if": {
+ "properties": {
+ "isMember": {
+ "const": true
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "membershipNumber": {
+ "type": "string",
+ "minLength": 10,
+ "maxLength": 10
+ }
+ }
+ },
+ "else": {
+ "properties": {
+ "membershipNumber": {
+ "type": "string",
+ "minLength": 15
+ }
+ }
+ }
+}
diff --git a/tests/Fixtures/json-schema-org/misc/regex-pattern.json b/tests/Fixtures/json-schema-org/misc/regex-pattern.json
new file mode 100644
index 0000000..7f1ac8e
--- /dev/null
+++ b/tests/Fixtures/json-schema-org/misc/regex-pattern.json
@@ -0,0 +1,12 @@
+{
+ "$id": "https://example.com/regex-pattern.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Regular Expression Pattern",
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string",
+ "pattern": "^[A-Z]{3}-\\d{3}$"
+ }
+ }
+}
diff --git a/tests/Support/SchemaRoundTrip.php b/tests/Support/SchemaRoundTrip.php
new file mode 100644
index 0000000..3b1745c
--- /dev/null
+++ b/tests/Support/SchemaRoundTrip.php
@@ -0,0 +1,111 @@
+
+ */
+ private const array IGNORED_KEYS = ['$schema'];
+
+ /**
+ * Assert every keyword from the source schema is captured in the converted output.
+ *
+ * @param array $source
+ * @param array $output
+ */
+ public static function assertSourceSubset(array $source, array $output, string $path = 'root'): void
+ {
+ foreach ($source as $key => $value) {
+ if (in_array($key, self::IGNORED_KEYS, strict: true)) {
+ continue;
+ }
+
+ $currentPath = $path === 'root' ? (string) $key : $path . '.' . $key;
+
+ if (! array_key_exists($key, $output)) {
+ throw new ExpectationFailedException(
+ sprintf('Expected output to contain key [%s] from source at path [%s].', $key, $currentPath),
+ );
+ }
+
+ $outputValue = $output[$key];
+
+ if (is_array($value) && is_array($outputValue)) {
+ if (array_is_list($value)) {
+ if (! array_is_list($outputValue)) {
+ throw new ExpectationFailedException(
+ sprintf('Expected list at path [%s], got associative array.', $currentPath),
+ );
+ }
+
+ if (count($value) !== count($outputValue)) {
+ throw new ExpectationFailedException(
+ sprintf(
+ 'Expected list length %d at path [%s], got %d.',
+ count($value),
+ $currentPath,
+ count($outputValue),
+ ),
+ );
+ }
+
+ foreach ($value as $index => $item) {
+ if (is_array($item)) {
+ if (! is_array($outputValue[$index] ?? null)) {
+ throw new ExpectationFailedException(
+ sprintf('Expected array at path [%s][%d].', $currentPath, $index),
+ );
+ }
+
+ self::assertSourceSubset($item, $outputValue[$index], $currentPath . '[' . $index . ']');
+ } elseif ($item !== ($outputValue[$index] ?? null)) {
+ throw new ExpectationFailedException(
+ sprintf(
+ 'Expected value %s at path [%s][%d], got %s.',
+ json_encode($item),
+ $currentPath,
+ $index,
+ json_encode($outputValue[$index] ?? null),
+ ),
+ );
+ }
+ }
+
+ continue;
+ }
+
+ self::assertSourceSubset($value, $outputValue, $currentPath);
+
+ continue;
+ }
+
+ if ($value !== $outputValue && ! self::areNumericEqual($value, $outputValue)) {
+ throw new ExpectationFailedException(
+ sprintf(
+ 'Expected value %s at path [%s], got %s.',
+ json_encode($value),
+ $currentPath,
+ json_encode($outputValue),
+ ),
+ );
+ }
+ }
+ }
+
+ /**
+ * Check whether two values are numerically equal across int/float boundaries.
+ */
+ private static function areNumericEqual(mixed $a, mixed $b): bool
+ {
+ return (is_int($a) && is_float($b) && $a === (int) $b)
+ || (is_float($a) && is_int($b) && $a === (float) $b);
+ }
+}
diff --git a/tests/Unit/Converters/EnumConverterTest.php b/tests/Unit/Converters/EnumConverterTest.php
index c67faaa..27c29bd 100644
--- a/tests/Unit/Converters/EnumConverterTest.php
+++ b/tests/Unit/Converters/EnumConverterTest.php
@@ -12,7 +12,9 @@
covers(EnumConverter::class);
it('can create a schema from an string backed enum', function (): void {
- /** This is the description of the string backed enum */
+ /**
+ * This is the description of the string backed enum
+ */
enum PostStatus: string
{
case Draft = 'draft';
@@ -33,7 +35,9 @@ enum PostStatus: string
});
it('can create a schema from an integer backed enum', function (): void {
- /** This is the description of the integer backed enum */
+ /**
+ * This is the description of the integer backed enum
+ */
enum PostType: int
{
case Article = 1;
diff --git a/tests/Unit/Converters/JsonConverterFixturesTest.php b/tests/Unit/Converters/JsonConverterFixturesTest.php
new file mode 100644
index 0000000..fc96dbb
--- /dev/null
+++ b/tests/Unit/Converters/JsonConverterFixturesTest.php
@@ -0,0 +1,59 @@
+
+ */
+function jsonSchemaOrgFixtures(): array
+{
+ $fixtures = [];
+ $basePath = __DIR__ . '/../../Fixtures/json-schema-org';
+
+ foreach (['misc', 'examples'] as $group) {
+ $directory = $basePath . '/' . $group;
+
+ if (! is_dir($directory)) {
+ continue;
+ }
+
+ foreach (new DirectoryIterator($directory) as $file) {
+ if ($file->isDot()) {
+ continue;
+ }
+
+ if ($file->getExtension() !== 'json') {
+ continue;
+ }
+
+ $name = $group . '/' . $file->getBasename('.json');
+ $fixtures[$name] = [$file->getPathname()];
+ }
+ }
+
+ ksort($fixtures);
+
+ return $fixtures;
+}
+
+dataset('json schema org fixtures', jsonSchemaOrgFixtures());
+
+it('round-trips json-schema.org fixtures', function (string $fixturePath): void {
+ $sourceJson = file_get_contents($fixturePath);
+
+ expect($sourceJson)->not->toBeFalse();
+
+ /** @var array $source */
+ $source = json_decode($sourceJson, true, flags: JSON_THROW_ON_ERROR);
+
+ $jsonSchema = Schema::fromJson($source);
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+
+ SchemaRoundTrip::assertSourceSubset($source, $output);
+})->with('json schema org fixtures');
diff --git a/tests/Unit/Converters/JsonConverterTest.php b/tests/Unit/Converters/JsonConverterTest.php
index 0e6276d..97d997c 100644
--- a/tests/Unit/Converters/JsonConverterTest.php
+++ b/tests/Unit/Converters/JsonConverterTest.php
@@ -15,6 +15,7 @@
use Cortex\JsonSchema\Types\BooleanSchema;
use Cortex\JsonSchema\Types\IntegerSchema;
use Cortex\JsonSchema\Contracts\JsonSchema;
+use Cortex\JsonSchema\Types\TypelessSchema;
use Cortex\JsonSchema\Converters\JsonConverter;
use Cortex\JsonSchema\Exceptions\SchemaException;
@@ -379,3 +380,296 @@
expect($output['contentSchema']['type'])->toBe('object');
expect($output['contentSchema']['properties']['name']['type'])->toBe('string');
});
+
+it('can handle conditional keywords', function (): void {
+ $json = [
+ 'type' => 'object',
+ 'if' => [
+ 'properties' => [
+ 'isMember' => [
+ 'const' => true,
+ ],
+ ],
+ ],
+ 'then' => [
+ 'properties' => [
+ 'membershipNumber' => [
+ 'type' => 'string',
+ 'minLength' => 10,
+ ],
+ ],
+ ],
+ 'else' => [
+ 'properties' => [
+ 'membershipNumber' => [
+ 'type' => 'string',
+ 'minLength' => 15,
+ ],
+ ],
+ ],
+ 'allOf' => [
+ [
+ 'type' => 'object',
+ ],
+ ],
+ 'anyOf' => [
+ [
+ 'type' => 'object',
+ ],
+ ],
+ 'oneOf' => [
+ [
+ 'type' => 'object',
+ ],
+ ],
+ 'not' => [
+ 'type' => 'null',
+ ],
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema)->toBeInstanceOf(ObjectSchema::class);
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output)->toHaveKey('if');
+ expect($output)->toHaveKey('then');
+ expect($output)->toHaveKey('else');
+ expect($output['allOf'])->toHaveCount(1);
+ expect($output['anyOf'])->toHaveCount(1);
+ expect($output['oneOf'])->toHaveCount(1);
+ expect($output['not']['type'])->toBe('null');
+});
+
+it('can handle $ref keyword', function (): void {
+ $json = [
+ 'type' => 'object',
+ '$ref' => '#/$defs/user',
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema->toArray(includeSchemaRef: false)['$ref'])->toBe('#/$defs/user');
+});
+
+it('can handle $defs keyword', function (): void {
+ $json = [
+ 'type' => 'object',
+ '$defs' => [
+ 'name' => [
+ 'type' => 'string',
+ ],
+ ],
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output['$defs']['name']['type'])->toBe('string');
+});
+
+it('can handle metadata keywords', function (): void {
+ $json = [
+ 'type' => 'string',
+ '$comment' => 'Internal note',
+ 'examples' => ['example@example.com'],
+ 'deprecated' => true,
+ 'readOnly' => true,
+ 'writeOnly' => true,
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2019_09);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema->toArray(includeSchemaRef: false))->toMatchArray([
+ 'type' => 'string',
+ '$comment' => 'Internal note',
+ 'examples' => ['example@example.com'],
+ 'deprecated' => true,
+ 'readOnly' => true,
+ 'writeOnly' => true,
+ ]);
+});
+
+it('can handle object pattern and property name keywords', function (): void {
+ $json = [
+ 'type' => 'object',
+ 'patternProperties' => [
+ '^S_' => [
+ 'type' => 'string',
+ ],
+ ],
+ 'propertyNames' => [
+ 'pattern' => '^[A-Za-z_]*$',
+ ],
+ 'minProperties' => 1,
+ 'maxProperties' => 5,
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output['patternProperties']['^S_']['type'])->toBe('string');
+ expect($output['propertyNames']['pattern'])->toBe('^[A-Za-z_]*$');
+ expect($output['minProperties'])->toBe(1);
+ expect($output['maxProperties'])->toBe(5);
+});
+
+it('can handle dependentSchemas and dependentRequired', function (): void {
+ $json = [
+ 'type' => 'object',
+ 'dependentRequired' => [
+ 'foo' => ['bar'],
+ ],
+ 'dependentSchemas' => [
+ 'foo' => [
+ 'required' => ['bar'],
+ ],
+ ],
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output['dependentRequired'])->toBe([
+ 'foo' => ['bar'],
+ ]);
+ expect($output['dependentSchemas']['foo']['required'])->toBe(['bar']);
+});
+
+it('can handle unevaluatedProperties', function (): void {
+ $json = [
+ 'type' => 'object',
+ 'unevaluatedProperties' => false,
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema->toArray(includeSchemaRef: false)['unevaluatedProperties'])->toBe(false);
+});
+
+it('can handle prefixItems and unevaluatedItems', function (): void {
+ $json = [
+ 'type' => 'array',
+ 'prefixItems' => [
+ [
+ 'type' => 'string',
+ ],
+ [
+ 'type' => 'integer',
+ ],
+ ],
+ 'unevaluatedItems' => false,
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output['prefixItems'])->toHaveCount(2);
+ expect($output['prefixItems'][0]['type'])->toBe('string');
+ expect($output['prefixItems'][1]['type'])->toBe('integer');
+ expect($output['unevaluatedItems'])->toBe(false);
+});
+
+it('can handle tuple items and additionalItems', function (): void {
+ $json = [
+ 'type' => 'array',
+ 'items' => [
+ [
+ 'type' => 'string',
+ ],
+ [
+ 'type' => 'integer',
+ ],
+ ],
+ 'additionalItems' => false,
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_07);
+ $jsonSchema = $converter->convert();
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output['items'])->toHaveCount(2);
+ expect($output['items'][0]['type'])->toBe('string');
+ expect($output['items'][1]['type'])->toBe('integer');
+ expect($output['additionalItems'])->toBe(false);
+});
+
+it('can handle $anchor keyword', function (): void {
+ $json = [
+ 'type' => 'object',
+ '$anchor' => 'ProductSchema',
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema->toArray(includeSchemaRef: false)['$anchor'])->toBe('ProductSchema');
+});
+
+it('can handle typeless structured schemas', function (): void {
+ $json = [
+ '$defs' => [
+ 'product' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'name' => [
+ 'type' => 'string',
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema)->toBeInstanceOf(TypelessSchema::class);
+
+ $output = $jsonSchema->toArray(includeSchemaRef: false);
+ expect($output)->not->toHaveKey('type');
+ expect($output['$defs']['product']['type'])->toBe('object');
+});
+
+it('can handle boolean const values', function (): void {
+ $json = [
+ 'type' => 'boolean',
+ 'const' => false,
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema->toArray(includeSchemaRef: false)['const'])->toBeFalse();
+});
+
+it('can handle number schema metadata and constraints', function (): void {
+ $json = [
+ 'type' => 'number',
+ 'minimum' => 0,
+ 'exclusiveMaximum' => 100,
+ 'enum' => [1.5, 2.5],
+ 'default' => 1.5,
+ 'description' => 'A number',
+ ];
+
+ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12);
+ $jsonSchema = $converter->convert();
+
+ expect($jsonSchema)->toBeInstanceOf(NumberSchema::class);
+ expect($jsonSchema->toArray(includeSchemaRef: false))->toMatchArray([
+ 'type' => 'number',
+ 'minimum' => 0,
+ 'exclusiveMaximum' => 100,
+ 'enum' => [1.5, 2.5],
+ 'default' => 1.5,
+ 'description' => 'A number',
+ ]);
+});
diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php
index 760ffac..efd7273 100644
--- a/tests/Unit/SchemaTest.php
+++ b/tests/Unit/SchemaTest.php
@@ -136,7 +136,9 @@ public function __construct(
});
it('can create a schema from an enum', function (): void {
- /** This is a custom enum for testing */
+ /**
+ * This is a custom enum for testing
+ */
enum UserRole: string
{
case Admin = 'admin';
diff --git a/tests/Unit/Types/TypelessSchemaTest.php b/tests/Unit/Types/TypelessSchemaTest.php
new file mode 100644
index 0000000..8333aaf
--- /dev/null
+++ b/tests/Unit/Types/TypelessSchemaTest.php
@@ -0,0 +1,52 @@
+oneOf(
+ Schema::object()->properties(
+ Schema::string('kind')->const('circle'),
+ Schema::number('radius')->required(),
+ ),
+ Schema::object()->properties(
+ Schema::string('kind')->const('square'),
+ Schema::number('size')->required(),
+ ),
+ );
+
+ expect($typelessSchema)->toBeInstanceOf(TypelessSchema::class);
+
+ $schemaArray = $typelessSchema->toArray();
+
+ expect($schemaArray)->not->toHaveKey('type');
+ expect($schemaArray)->toHaveKey('title', 'shape');
+ expect($schemaArray['oneOf'])->toHaveCount(2);
+});
+
+it('can create a typeless definition-only schema', function (): void {
+ $typelessSchema = Schema::typeless()
+ ->addDefinition('address', Schema::object()->properties(
+ Schema::string('street')->required(),
+ Schema::string('city')->required(),
+ ));
+
+ $schemaArray = $typelessSchema->toArray();
+
+ expect($schemaArray)->not->toHaveKey('type');
+ expect($schemaArray['$defs']['address']['type'])->toBe('object');
+});
+
+it('stays typeless when nullable is called', function (): void {
+ $typelessSchema = Schema::typeless('shape')->nullable();
+
+ expect($typelessSchema)->toBeInstanceOf(TypelessSchema::class);
+ expect($typelessSchema->toArray())->not->toHaveKey('type');
+});