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'); +});