From b8f41560c1394ffb4c7335b1332ab6e4b787a0b6 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 11 Jun 2026 08:59:23 +0100 Subject: [PATCH 1/6] refactor: JSON converter inprovovements --- ecs.php | 1 - src/Converters/JsonConverter.php | 727 +++++++++++++----- src/Schema.php | 10 + src/Types/AbstractSchema.php | 34 +- src/Types/Concerns/HasAnchor.php | 57 ++ src/Types/Concerns/HasConditionals.php | 4 +- src/Types/Concerns/HasConst.php | 4 +- src/Types/Concerns/HasDescription.php | 4 +- src/Types/Concerns/HasEnum.php | 35 +- src/Types/Concerns/HasFormat.php | 4 +- src/Types/Concerns/HasId.php | 4 +- src/Types/Concerns/HasInitialTitle.php | 4 +- src/Types/Concerns/HasItems.php | 49 +- src/Types/Concerns/HasMetadata.php | 4 +- src/Types/Concerns/HasNumericConstraints.php | 4 +- src/Types/Concerns/HasProperties.php | 44 +- src/Types/Concerns/HasReadWrite.php | 4 +- src/Types/Concerns/HasRequired.php | 4 +- src/Types/Concerns/HasTitle.php | 4 +- src/Types/Concerns/HasValidation.php | 4 +- .../Concerns/ValidatesVersionFeatures.php | 4 +- src/Types/ObjectSchema.php | 1 + src/Types/UnionSchema.php | 11 + .../json-schema-org/examples/address.json | 38 + .../json-schema-org/examples/blog-post.json | 43 ++ .../json-schema-org/examples/calendar.json | 67 ++ .../json-schema-org/examples/device.json | 54 ++ .../json-schema-org/examples/ecommerce.json | 34 + .../examples/geographical-location.json | 20 + .../examples/health-record.json | 55 ++ .../json-schema-org/examples/job-posting.json | 32 + .../json-schema-org/examples/movie.json | 32 + .../examples/user-profile.json | 32 + .../Fixtures/json-schema-org/misc/arrays.json | 40 + .../json-schema-org/misc/basic-person.json | 21 + .../json-schema-org/misc/complex-object.json | 41 + .../misc/dependent-required.json | 17 + .../misc/dependent-schemas.json | 25 + .../misc/enumerated-values.json | 11 + .../json-schema-org/misc/if-else.json | 39 + .../json-schema-org/misc/regex-pattern.json | 12 + tests/Support/SchemaRoundTrip.php | 122 +++ tests/Unit/Converters/EnumConverterTest.php | 8 +- .../Converters/JsonConverterFixturesTest.php | 59 ++ tests/Unit/Converters/JsonConverterTest.php | 293 +++++++ tests/Unit/SchemaTest.php | 4 +- 46 files changed, 1894 insertions(+), 226 deletions(-) create mode 100644 src/Types/Concerns/HasAnchor.php create mode 100644 tests/Fixtures/json-schema-org/examples/address.json create mode 100644 tests/Fixtures/json-schema-org/examples/blog-post.json create mode 100644 tests/Fixtures/json-schema-org/examples/calendar.json create mode 100644 tests/Fixtures/json-schema-org/examples/device.json create mode 100644 tests/Fixtures/json-schema-org/examples/ecommerce.json create mode 100644 tests/Fixtures/json-schema-org/examples/geographical-location.json create mode 100644 tests/Fixtures/json-schema-org/examples/health-record.json create mode 100644 tests/Fixtures/json-schema-org/examples/job-posting.json create mode 100644 tests/Fixtures/json-schema-org/examples/movie.json create mode 100644 tests/Fixtures/json-schema-org/examples/user-profile.json create mode 100644 tests/Fixtures/json-schema-org/misc/arrays.json create mode 100644 tests/Fixtures/json-schema-org/misc/basic-person.json create mode 100644 tests/Fixtures/json-schema-org/misc/complex-object.json create mode 100644 tests/Fixtures/json-schema-org/misc/dependent-required.json create mode 100644 tests/Fixtures/json-schema-org/misc/dependent-schemas.json create mode 100644 tests/Fixtures/json-schema-org/misc/enumerated-values.json create mode 100644 tests/Fixtures/json-schema-org/misc/if-else.json create mode 100644 tests/Fixtures/json-schema-org/misc/regex-pattern.json create mode 100644 tests/Support/SchemaRoundTrip.php create mode 100644 tests/Unit/Converters/JsonConverterFixturesTest.php 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..8d0aafd 100644 --- a/src/Converters/JsonConverter.php +++ b/src/Converters/JsonConverter.php @@ -35,7 +35,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 +52,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 +64,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,7 +91,7 @@ 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 + null => $this->createUnionSchema($title), default => throw new SchemaException('Unsupported schema type: ' . (is_string($type) ? $type : gettype( $type, ))), @@ -137,19 +149,85 @@ private function getValue(string $key): mixed } /** - * Get a const value that's properly typed for schema. + * Infer a schema type from present validation keywords. */ - private function getConstValue(string $key): bool|float|int|string|null + private function inferTypeFromKeywords(): ?string { - $value = $this->getValue($key); + $stringKeywords = ['pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType']; + + foreach ($stringKeywords as $stringKeyword) { + if (array_key_exists($stringKeyword, $this->data)) { + return 'string'; + } + } + + $numericKeywords = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']; + + foreach ($numericKeywords as $numericKeyword) { + if (array_key_exists($numericKeyword, $this->data)) { + $value = $this->getValue($numericKeyword); + + return is_int($value) || (is_float($value) && floor($value) === $value) ? 'integer' : 'number'; + } + } - if (is_bool($value) || is_float($value) || is_int($value) || is_string($value) || $value === null) { - return $value; + 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; } + /** + * Determine whether this schema should omit the type keyword. + */ + private function shouldUseTypelessSchema(): bool + { + if (array_key_exists('type', $this->data)) { + return false; + } + + $structuralKeywords = [ + '$ref', + 'allOf', + 'anyOf', + 'oneOf', + 'not', + 'if', + 'then', + 'else', + '$defs', + 'definitions', + 'properties', + 'patternProperties', + 'dependentSchemas', + 'dependentRequired', + 'required', + ]; + + foreach ($structuralKeywords as $structuralKeyword) { + if (array_key_exists($structuralKeyword, $this->data)) { + return true; + } + } + + return false; + } + /** * Apply shared fields to the schema. */ @@ -161,212 +239,322 @@ private function applyId(AbstractSchema $schema): void } /** - * Detect schema version from $schema URI. + * Apply keywords shared across all schema types. */ - private function detectSchemaVersion(string $schemaUri): SchemaVersion - { - 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, - }; - } - - private function createStringSchema(?string $title): StringSchema + private function applyCommonKeywords(AbstractSchema $schema): void { - $stringSchema = new StringSchema($title, $this->schemaVersion); - $this->applyId($stringSchema); + $this->applyId($schema); - if (($minLength = $this->getInt('minLength')) !== null) { - $stringSchema->minLength($minLength); + if (($anchor = $this->getString('$anchor')) !== null) { + $schema->anchor($anchor); } - if (($maxLength = $this->getInt('maxLength')) !== null) { - $stringSchema->maxLength($maxLength); + if (($description = $this->getString('description')) !== null) { + $schema->description($description); } - if (($pattern = $this->getString('pattern')) !== null) { - $stringSchema->pattern($pattern); + if (($comment = $this->getString('$comment')) !== null) { + $schema->comment($comment); } - if (($contentEncoding = $this->getString('contentEncoding')) !== null) { - $stringSchema->contentEncoding($contentEncoding); + if (array_key_exists('default', $this->data)) { + $schema->default($this->getValue('default')); } - if (($contentMediaType = $this->getString('contentMediaType')) !== null) { - $stringSchema->contentMediaType($contentMediaType); + if ($this->getBool('deprecated')) { + $schema->deprecated(); } - $contentSchema = $this->getValue('contentSchema'); - - 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 schema definitions. + */ + 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; + } + + $converter = new self($definitionData, $this->schemaVersion); + $schema->addDefinition($name, $converter->convert()); + } } - private function createIntegerSchema(?string $title): IntegerSchema + /** + * Apply object-specific keywords. + */ + private function applyObjectKeywords(ObjectSchema|UnionSchema $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 = []; + + foreach ($properties as $name => $propertyData) { + if (! is_string($name)) { + continue; + } + + if (! is_array($propertyData)) { + continue; + } + + $converter = new self($propertyData, $this->schemaVersion); + $propertySchema = $converter->convert(); + + $propertySchemas[$name] = $propertySchema; + + if (in_array($name, $required, true)) { + $requiredProps[] = $name; + } + } + + $reflectionClass = new ReflectionClass($objectSchema); + $propertiesProperty = $reflectionClass->getProperty('properties'); + $propertiesProperty->setValue($objectSchema, $propertySchemas); - if (($maximum = $this->getInt('maximum')) !== null) { - $integerSchema->maximum($maximum); + $requiredProperty = $reflectionClass->getProperty('requiredProperties'); + $requiredProperty->setValue($objectSchema, $requiredProps); + } elseif ($required !== []) { + $requiredProps = array_values(array_filter( + $required, + is_string(...), + )); + + if ($requiredProps !== []) { + $reflectionClass = new ReflectionClass($objectSchema); + $requiredProperty = $reflectionClass->getProperty('requiredProperties'); + $requiredProperty->setValue($objectSchema, $requiredProps); + } } - if (($exclusiveMinimum = $this->getInt('exclusiveMinimum')) !== null) { - $integerSchema->exclusiveMinimum($exclusiveMinimum); + if (($patternProperties = $this->getArray('patternProperties')) !== null) { + foreach ($patternProperties as $pattern => $propertyData) { + if (! is_string($pattern)) { + continue; + } + + if (! is_array($propertyData)) { + continue; + } + + $converter = new self($propertyData, $this->schemaVersion); + $objectSchema->patternProperty($pattern, $converter->convert()); + } } - if (($exclusiveMaximum = $this->getInt('exclusiveMaximum')) !== null) { - $integerSchema->exclusiveMaximum($exclusiveMaximum); + if (($propertyNames = $this->getArray('propertyNames')) !== null) { + $converter = new self($propertyNames, $this->schemaVersion); + $objectSchema->propertyNames($converter->convert()); } - if (($multipleOf = $this->getInt('multipleOf')) !== null) { - $integerSchema->multipleOf($multipleOf); + $additionalProperties = $this->getValue('additionalProperties'); + + if ($additionalProperties !== null) { + if (is_bool($additionalProperties)) { + $objectSchema->additionalProperties($additionalProperties); + } elseif (is_array($additionalProperties)) { + $converter = new self($additionalProperties, $this->schemaVersion); + $objectSchema->additionalProperties($converter->convert()); + } } - if (($enum = $this->getArray('enum')) !== null && $enum !== []) { - /** @var non-empty-array $enum */ - $integerSchema->enum($enum); + $unevaluatedProperties = $this->getValue('unevaluatedProperties'); + + if ($unevaluatedProperties !== null) { + if (is_bool($unevaluatedProperties)) { + $objectSchema->unevaluatedProperties($unevaluatedProperties); + } elseif (is_array($unevaluatedProperties)) { + $converter = new self($unevaluatedProperties, $this->schemaVersion); + $objectSchema->unevaluatedProperties($converter->convert()); + } } - if (($const = $this->getConstValue('const')) !== null) { - $integerSchema->const($const); + if (($dependentSchemas = $this->getArray('dependentSchemas')) !== null) { + foreach ($dependentSchemas as $property => $dependentData) { + if (! is_string($property)) { + continue; + } + + if (! is_array($dependentData)) { + continue; + } + + $converter = new self($dependentData, $this->schemaVersion); + $objectSchema->dependentSchema($property, $converter->convert()); + } } - if (($default = $this->getValue('default')) !== null) { - $integerSchema->default($default); + if (($dependentRequired = $this->getArray('dependentRequired')) !== null) { + /** @var array> $normalized */ + $normalized = []; + + 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 (($description = $this->getString('description')) !== null) { - $integerSchema->description($description); + if (($minProperties = $this->getInt('minProperties')) !== null) { + $objectSchema->minProperties($minProperties); } - return $integerSchema; + if (($maxProperties = $this->getInt('maxProperties')) !== null) { + $objectSchema->maxProperties($maxProperties); + } } - private function createBooleanSchema(?string $title): BooleanSchema + /** + * Apply array-specific keywords. + */ + private function applyArrayKeywords(ArraySchema $arraySchema): void { - $booleanSchema = new BooleanSchema($title, $this->schemaVersion); - $this->applyId($booleanSchema); + $items = $this->getValue('items'); - if (($const = $this->getConstValue('const')) !== null) { - $booleanSchema->const($const); - } + if (is_array($items)) { + if ($this->isListArray($items)) { + $tupleSchemas = []; - if (($default = $this->getValue('default')) !== null) { - $booleanSchema->default($default); - } + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } - if (($description = $this->getString('description')) !== null) { - $booleanSchema->description($description); + $converter = new self($item, $this->schemaVersion); + $tupleSchemas[] = $converter->convert(); + } + + if ($tupleSchemas !== []) { + $arraySchema->tupleItems($tupleSchemas); + } + } else { + $converter = new self($items, $this->schemaVersion); + $arraySchema->items($converter->convert()); + } } - if ($this->getBool('readOnly')) { - $booleanSchema->readOnly(); + $additionalItems = $this->getValue('additionalItems'); + + if ($additionalItems !== null) { + if (is_bool($additionalItems)) { + $arraySchema->additionalItems($additionalItems); + } elseif (is_array($additionalItems)) { + $converter = new self($additionalItems, $this->schemaVersion); + $arraySchema->additionalItems($converter->convert()); + } } - return $booleanSchema; - } + if (($prefixItems = $this->getArray('prefixItems')) !== null && $this->isListArray($prefixItems)) { + $prefixSchemas = []; - private function createArraySchema(?string $title): ArraySchema - { - $arraySchema = new ArraySchema($title, $this->schemaVersion); - $this->applyId($arraySchema); + foreach ($prefixItems as $prefixItem) { + if (! is_array($prefixItem)) { + continue; + } + + $converter = new self($prefixItem, $this->schemaVersion); + $prefixSchemas[] = $converter->convert(); + } - if (($items = $this->getArray('items')) !== null) { - $converter = new self($items, $this->schemaVersion); - $itemSchema = $converter->convert(); - $arraySchema->items($itemSchema); + if ($prefixSchemas !== []) { + $arraySchema->prefixItems($prefixSchemas); + } } if (($minItems = $this->getInt('minItems')) !== null) { @@ -383,8 +571,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($converter->convert()); } if (($minContains = $this->getInt('minContains')) !== null) { @@ -395,95 +582,252 @@ private function createArraySchema(?string $title): ArraySchema $arraySchema->maxContains($maxContains); } - if (($description = $this->getString('description')) !== null) { - $arraySchema->description($description); + $unevaluatedItems = $this->getValue('unevaluatedItems'); + + if ($unevaluatedItems !== null) { + if (is_bool($unevaluatedItems)) { + $arraySchema->unevaluatedItems($unevaluatedItems); + } elseif (is_array($unevaluatedItems)) { + $converter = new self($unevaluatedItems, $this->schemaVersion); + $arraySchema->unevaluatedItems($converter->convert()); + } } + } - 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) { + 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 $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 subschema value to a JsonSchema instance. + */ + 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(); + } - $converter = new self($propertyData, $this->schemaVersion); - $propertySchema = $converter->convert(); + /** + * Convert an array of subschemas. + * + * @return array + */ + private function getArrayOfSchemas(string $key): array + { + $value = $this->getArray($key); - $propertySchemas[$name] = $propertySchema; + if ($value === null || ! $this->isListArray($value)) { + return []; + } - // Track required properties - if (in_array($name, $required, true)) { - $requiredProps[] = $name; - } + $schemas = []; + + foreach ($value as $item) { + if (! is_array($item)) { + continue; } - // Set properties and required directly using reflection to avoid title requirement - $reflectionClass = new ReflectionClass($objectSchema); - $propertiesProperty = $reflectionClass->getProperty('properties'); - $propertiesProperty->setValue($objectSchema, $propertySchemas); + $schemas[] = (new self($item, $this->schemaVersion))->convert(); + } - $requiredProperty = $reflectionClass->getProperty('requiredProperties'); - $requiredProperty->setValue($objectSchema, $requiredProps); + return $schemas; + } + + /** + * Determine if an array is a list (sequential integer keys). + * + * @param array $array + */ + private function isListArray(array $array): bool + { + if ($array === []) { + return true; } - $additionalProperties = $this->getValue('additionalProperties'); + return array_keys($array) === range(0, count($array) - 1); + } - 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 createTypelessSchema(?string $title): UnionSchema + { + $unionSchema = UnionSchema::typeless($title, $this->schemaVersion); + $this->applyCommonKeywords($unionSchema); + + if (array_key_exists('properties', $this->data) || array_key_exists( + 'patternProperties', + $this->data, + ) || array_key_exists( + 'required', + $this->data, + )) { + $this->applyObjectKeywords($unionSchema); } - if (($minProperties = $this->getInt('minProperties')) !== null) { - $objectSchema->minProperties($minProperties); + return $unionSchema; + } + + 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 (($maxProperties = $this->getInt('maxProperties')) !== null) { - $objectSchema->maxProperties($maxProperties); + if (($maxLength = $this->getInt('maxLength')) !== null) { + $stringSchema->maxLength($maxLength); } - if (($description = $this->getString('description')) !== null) { - $objectSchema->description($description); + if (($pattern = $this->getString('pattern')) !== null) { + $stringSchema->pattern($pattern); + } + + 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)) { + $converter = new self($contentSchema, $this->schemaVersion); + $stringSchema->contentSchema($converter->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); @@ -492,24 +836,11 @@ private function createUnionSchema(?string $title): UnionSchema $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..871bb80 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -188,4 +188,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..9d45018 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; @@ -43,6 +45,8 @@ abstract class AbstractSchema implements JsonSchema protected SchemaVersion $schemaVersion = SchemaVersion::Draft_2020_12; + protected bool $omitType = false; + /** * @param \Cortex\JsonSchema\Enums\SchemaType|array $type */ @@ -74,6 +78,24 @@ public function getVersion(): SchemaVersion return $this->schemaVersion; } + /** + * Omit the type keyword when converting to array. + */ + public function omitType(bool $omit = true): static + { + $this->omitType = $omit; + + return $this; + } + + /** + * Determine if the type keyword should be omitted. + */ + public function shouldOmitType(): bool + { + return $this->omitType; + } + /** * Add null type to schema. */ @@ -105,17 +127,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->omitType) { + $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 +183,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..f97a87c 100644 --- a/src/Types/Concerns/HasEnum.php +++ b/src/Types/Concerns/HasEnum.php @@ -4,22 +4,49 @@ namespace Cortex\JsonSchema\Types\Concerns; -/** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */ +use Cortex\JsonSchema\Exceptions\SchemaException; + +/** + * @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) { + $alreadyExists = false; + + foreach ($unique as $existing) { + if ($existing === $value) { + $alreadyExists = true; + + break; + } + } + + if (! $alreadyExists) { + $unique[] = $value; + } + } + + if ($unique === []) { + throw new SchemaException('Enum must contain at least one 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/UnionSchema.php b/src/Types/UnionSchema.php index d08052f..73ec804 100644 --- a/src/Types/UnionSchema.php +++ b/src/Types/UnionSchema.php @@ -58,4 +58,15 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true return $this->addPropertiesToSchema($schema); } + + /** + * Create a typeless schema for composition-only or definition-only documents. + */ + public static function typeless(?string $title = null, ?SchemaVersion $schemaVersion = null): self + { + $schema = new self([SchemaType::String], $title, $schemaVersion); + $schema->omitType(); + + return $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..55af9cc --- /dev/null +++ b/tests/Support/SchemaRoundTrip.php @@ -0,0 +1,122 @@ + + */ + 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, 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 (self::isList($value)) { + if (! self::isList($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) { + if (is_int($value) && is_float($outputValue) && $value === (int) $outputValue) { + continue; + } + + if (is_float($value) && is_int($outputValue) && $value === (float) $outputValue) { + continue; + } + + throw new ExpectationFailedException( + sprintf( + 'Expected value %s at path [%s], got %s.', + json_encode($value), + $currentPath, + json_encode($outputValue), + ), + ); + } + } + } + + /** + * @param array $array + */ + private static function isList(array $array): bool + { + if ($array === []) { + return true; + } + + return array_keys($array) === range(0, count($array) - 1); + } +} 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..e28e624 100644 --- a/tests/Unit/Converters/JsonConverterTest.php +++ b/tests/Unit/Converters/JsonConverterTest.php @@ -379,3 +379,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(UnionSchema::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'; From e63df64c6dd837b00238f58d0f179eea17c85e73 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 11 Jun 2026 09:04:52 +0100 Subject: [PATCH 2/6] cleanup --- src/Converters/JsonConverter.php | 290 ++++++++++-------------------- src/Types/AbstractSchema.php | 8 - src/Types/Concerns/HasEnum.php | 18 +- tests/Support/SchemaRoundTrip.php | 27 +-- 4 files changed, 102 insertions(+), 241 deletions(-) diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php index 8d0aafd..036ab1b 100644 --- a/src/Converters/JsonConverter.php +++ b/src/Converters/JsonConverter.php @@ -92,9 +92,9 @@ public function convert(): JsonSchema 'object' => $this->createObjectSchema($title), 'null' => $this->createNullSchema($title), null => $this->createUnionSchema($title), - default => throw new SchemaException('Unsupported schema type: ' . (is_string($type) ? $type : gettype( - $type, - ))), + default => throw new SchemaException( + 'Unsupported schema type: ' . (is_string($type) ? $type : gettype($type)), + ), }; } @@ -149,23 +149,39 @@ private function getValue(string $key): mixed } /** - * Infer a schema type from present validation keywords. + * Resolve a keyword value that may be a boolean or a subschema object. */ - private function inferTypeFromKeywords(): ?string + private function getBoolOrSchema(string $key): bool|JsonSchema|null { - $stringKeywords = ['pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType']; + $value = $this->getValue($key); - foreach ($stringKeywords as $stringKeyword) { - if (array_key_exists($stringKeyword, $this->data)) { - return 'string'; - } + if (is_bool($value)) { + return $value; } - $numericKeywords = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']; + if (is_array($value)) { + return (new self($value, $this->schemaVersion))->convert(); + } + + return null; + } - foreach ($numericKeywords as $numericKeyword) { - if (array_key_exists($numericKeyword, $this->data)) { - $value = $this->getValue($numericKeyword); + /** + * Infer a schema type from present validation keywords when no explicit type is given. + */ + private function inferTypeFromKeywords(): ?string + { + $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'; } @@ -201,49 +217,23 @@ private function shouldUseTypelessSchema(): bool return false; } - $structuralKeywords = [ - '$ref', - 'allOf', - 'anyOf', - 'oneOf', - 'not', - 'if', - 'then', - 'else', - '$defs', - 'definitions', - 'properties', - 'patternProperties', - 'dependentSchemas', - 'dependentRequired', - 'required', - ]; - - foreach ($structuralKeywords as $structuralKeyword) { - if (array_key_exists($structuralKeyword, $this->data)) { - return true; - } - } + $structuralKeywords = array_flip([ + '$ref', 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', + '$defs', 'definitions', 'properties', 'patternProperties', + 'dependentSchemas', 'dependentRequired', 'required', + ]); - return false; + return array_intersect_key($this->data, $structuralKeywords) !== []; } /** - * Apply shared fields to the schema. + * Apply keywords shared across all schema types. */ - private function applyId(AbstractSchema $schema): void + private function applyCommonKeywords(AbstractSchema $schema): void { if (($id = $this->getString('$id')) !== null) { $schema->id($id); } - } - - /** - * Apply keywords shared across all schema types. - */ - private function applyCommonKeywords(AbstractSchema $schema): void - { - $this->applyId($schema); if (($anchor = $this->getString('$anchor')) !== null) { $schema->anchor($anchor); @@ -325,19 +315,19 @@ private function applyConditionals(AbstractSchema $schema): void if (($if = $this->convertSubschema($this->getValue('if'))) instanceof JsonSchema) { $schema->if($if); - } - if (($then = $this->convertSubschema($this->getValue('then'))) instanceof JsonSchema) { - $schema->then($then); - } + if (($then = $this->convertSubschema($this->getValue('then'))) instanceof JsonSchema) { + $schema->then($then); + } - if (($else = $this->convertSubschema($this->getValue('else'))) instanceof JsonSchema) { - $schema->else($else); + if (($else = $this->convertSubschema($this->getValue('else'))) instanceof JsonSchema) { + $schema->else($else); + } } } /** - * Apply schema definitions. + * Apply $defs / definitions to the schema. */ private function applyDefinitions(AbstractSchema $schema): void { @@ -355,9 +345,7 @@ private function applyDefinitions(AbstractSchema $schema): void if (! is_array($definitionData)) { continue; } - - $converter = new self($definitionData, $this->schemaVersion); - $schema->addDefinition($name, $converter->convert()); + $schema->addDefinition($name, (new self($definitionData, $this->schemaVersion))->convert()); } } @@ -380,33 +368,22 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($propertyData)) { continue; } - - $converter = new self($propertyData, $this->schemaVersion); - $propertySchema = $converter->convert(); - - $propertySchemas[$name] = $propertySchema; + $propertySchemas[$name] = (new self($propertyData, $this->schemaVersion))->convert(); if (in_array($name, $required, true)) { $requiredProps[] = $name; } } - $reflectionClass = new ReflectionClass($objectSchema); - $propertiesProperty = $reflectionClass->getProperty('properties'); - $propertiesProperty->setValue($objectSchema, $propertySchemas); - - $requiredProperty = $reflectionClass->getProperty('requiredProperties'); - $requiredProperty->setValue($objectSchema, $requiredProps); + $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(...), - )); + $requiredProps = array_values(array_filter($required, is_string(...))); if ($requiredProps !== []) { - $reflectionClass = new ReflectionClass($objectSchema); - $requiredProperty = $reflectionClass->getProperty('requiredProperties'); - $requiredProperty->setValue($objectSchema, $requiredProps); + $reflection = new ReflectionClass($objectSchema); + $reflection->getProperty('requiredProperties')->setValue($objectSchema, $requiredProps); } } @@ -419,37 +396,20 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($propertyData)) { continue; } - - $converter = new self($propertyData, $this->schemaVersion); - $objectSchema->patternProperty($pattern, $converter->convert()); + $objectSchema->patternProperty($pattern, (new self($propertyData, $this->schemaVersion))->convert()); } } if (($propertyNames = $this->getArray('propertyNames')) !== null) { - $converter = new self($propertyNames, $this->schemaVersion); - $objectSchema->propertyNames($converter->convert()); + $objectSchema->propertyNames((new self($propertyNames, $this->schemaVersion))->convert()); } - $additionalProperties = $this->getValue('additionalProperties'); - - if ($additionalProperties !== null) { - if (is_bool($additionalProperties)) { - $objectSchema->additionalProperties($additionalProperties); - } elseif (is_array($additionalProperties)) { - $converter = new self($additionalProperties, $this->schemaVersion); - $objectSchema->additionalProperties($converter->convert()); - } + if (($additionalProperties = $this->getBoolOrSchema('additionalProperties')) !== null) { + $objectSchema->additionalProperties($additionalProperties); } - $unevaluatedProperties = $this->getValue('unevaluatedProperties'); - - if ($unevaluatedProperties !== null) { - if (is_bool($unevaluatedProperties)) { - $objectSchema->unevaluatedProperties($unevaluatedProperties); - } elseif (is_array($unevaluatedProperties)) { - $converter = new self($unevaluatedProperties, $this->schemaVersion); - $objectSchema->unevaluatedProperties($converter->convert()); - } + if (($unevaluatedProperties = $this->getBoolOrSchema('unevaluatedProperties')) !== null) { + $objectSchema->unevaluatedProperties($unevaluatedProperties); } if (($dependentSchemas = $this->getArray('dependentSchemas')) !== null) { @@ -461,9 +421,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($dependentData)) { continue; } - - $converter = new self($dependentData, $this->schemaVersion); - $objectSchema->dependentSchema($property, $converter->convert()); + $objectSchema->dependentSchema($property, (new self($dependentData, $this->schemaVersion))->convert()); } } @@ -479,11 +437,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($requiredProperties)) { continue; } - - $normalized[$property] = array_values(array_filter( - $requiredProperties, - is_string(...), - )); + $normalized[$property] = array_values(array_filter($requiredProperties, is_string(...))); } if ($normalized !== []) { @@ -508,49 +462,29 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void $items = $this->getValue('items'); if (is_array($items)) { - if ($this->isListArray($items)) { - $tupleSchemas = []; - - foreach ($items as $item) { - if (! is_array($item)) { - continue; - } - - $converter = new self($item, $this->schemaVersion); - $tupleSchemas[] = $converter->convert(); - } + 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 { - $converter = new self($items, $this->schemaVersion); - $arraySchema->items($converter->convert()); + $arraySchema->items((new self($items, $this->schemaVersion))->convert()); } } - $additionalItems = $this->getValue('additionalItems'); - - if ($additionalItems !== null) { - if (is_bool($additionalItems)) { - $arraySchema->additionalItems($additionalItems); - } elseif (is_array($additionalItems)) { - $converter = new self($additionalItems, $this->schemaVersion); - $arraySchema->additionalItems($converter->convert()); - } + if (($additionalItems = $this->getBoolOrSchema('additionalItems')) !== null) { + $arraySchema->additionalItems($additionalItems); } - if (($prefixItems = $this->getArray('prefixItems')) !== null && $this->isListArray($prefixItems)) { - $prefixSchemas = []; - - foreach ($prefixItems as $prefixItem) { - if (! is_array($prefixItem)) { - continue; - } - - $converter = new self($prefixItem, $this->schemaVersion); - $prefixSchemas[] = $converter->convert(); - } + 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); @@ -570,8 +504,7 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void } if (($contains = $this->getArray('contains')) !== null) { - $converter = new self($contains, $this->schemaVersion); - $arraySchema->contains($converter->convert()); + $arraySchema->contains((new self($contains, $this->schemaVersion))->convert()); } if (($minContains = $this->getInt('minContains')) !== null) { @@ -582,15 +515,8 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void $arraySchema->maxContains($maxContains); } - $unevaluatedItems = $this->getValue('unevaluatedItems'); - - if ($unevaluatedItems !== null) { - if (is_bool($unevaluatedItems)) { - $arraySchema->unevaluatedItems($unevaluatedItems); - } elseif (is_array($unevaluatedItems)) { - $converter = new self($unevaluatedItems, $this->schemaVersion); - $arraySchema->unevaluatedItems($converter->convert()); - } + if (($unevaluatedItems = $this->getBoolOrSchema('unevaluatedItems')) !== null) { + $arraySchema->unevaluatedItems($unevaluatedItems); } } @@ -649,7 +575,7 @@ private function applyNumericKeywords(AbstractSchema $schema): void } /** - * Detect schema version from $schema URI. + * Detect schema version from a $schema URI. */ private function detectSchemaVersion(string $schemaUri): SchemaVersion { @@ -663,7 +589,7 @@ private function detectSchemaVersion(string $schemaUri): SchemaVersion } /** - * Convert a subschema value to a JsonSchema instance. + * Convert a raw value to a JsonSchema instance if it is an array subschema. */ private function convertSubschema(mixed $value): ?JsonSchema { @@ -675,7 +601,7 @@ private function convertSubschema(mixed $value): ?JsonSchema } /** - * Convert an array of subschemas. + * Convert an array of raw subschema objects to JsonSchema instances. * * @return array */ @@ -683,35 +609,14 @@ private function getArrayOfSchemas(string $key): array { $value = $this->getArray($key); - if ($value === null || ! $this->isListArray($value)) { + if ($value === null || ! array_is_list($value)) { return []; } - $schemas = []; - - foreach ($value as $item) { - if (! is_array($item)) { - continue; - } - - $schemas[] = (new self($item, $this->schemaVersion))->convert(); - } - - return $schemas; - } - - /** - * Determine if an array is a list (sequential integer keys). - * - * @param array $array - */ - private function isListArray(array $array): bool - { - if ($array === []) { - return true; - } - - return array_keys($array) === range(0, count($array) - 1); + return array_values(array_map( + fn(array $item): JsonSchema => (new self($item, $this->schemaVersion))->convert(), + array_filter($value, is_array(...)), + )); } private function createTypelessSchema(?string $title): UnionSchema @@ -719,13 +624,9 @@ private function createTypelessSchema(?string $title): UnionSchema $unionSchema = UnionSchema::typeless($title, $this->schemaVersion); $this->applyCommonKeywords($unionSchema); - if (array_key_exists('properties', $this->data) || array_key_exists( - 'patternProperties', - $this->data, - ) || array_key_exists( - 'required', - $this->data, - )) { + $objectKeywords = array_flip(['properties', 'patternProperties', 'required']); + + if (array_intersect_key($this->data, $objectKeywords) !== []) { $this->applyObjectKeywords($unionSchema); } @@ -760,8 +661,7 @@ private function createStringSchema(?string $title): StringSchema $contentSchema = $this->getValue('contentSchema'); if (is_array($contentSchema)) { - $converter = new self($contentSchema, $this->schemaVersion); - $stringSchema->contentSchema($converter->convert()); + $stringSchema->contentSchema((new self($contentSchema, $this->schemaVersion))->convert()); } elseif (is_bool($contentSchema)) { $stringSchema->contentSchema($contentSchema); } @@ -826,14 +726,10 @@ private function createUnionSchema(?string $title): UnionSchema $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 { $schema = new UnionSchema(SchemaType::cases(), $title, $this->schemaVersion); diff --git a/src/Types/AbstractSchema.php b/src/Types/AbstractSchema.php index 9d45018..5e97ae1 100644 --- a/src/Types/AbstractSchema.php +++ b/src/Types/AbstractSchema.php @@ -88,14 +88,6 @@ public function omitType(bool $omit = true): static return $this; } - /** - * Determine if the type keyword should be omitted. - */ - public function shouldOmitType(): bool - { - return $this->omitType; - } - /** * Add null type to schema. */ diff --git a/src/Types/Concerns/HasEnum.php b/src/Types/Concerns/HasEnum.php index f97a87c..28cdbff 100644 --- a/src/Types/Concerns/HasEnum.php +++ b/src/Types/Concerns/HasEnum.php @@ -4,8 +4,6 @@ namespace Cortex\JsonSchema\Types\Concerns; -use Cortex\JsonSchema\Exceptions\SchemaException; - /** * @mixin \Cortex\JsonSchema\Contracts\JsonSchema */ @@ -26,25 +24,11 @@ public function enum(array $values): static $unique = []; foreach ($values as $value) { - $alreadyExists = false; - - foreach ($unique as $existing) { - if ($existing === $value) { - $alreadyExists = true; - - break; - } - } - - if (! $alreadyExists) { + if (! in_array($value, $unique, strict: true)) { $unique[] = $value; } } - if ($unique === []) { - throw new SchemaException('Enum must contain at least one value'); - } - /** @var non-empty-array $unique */ $this->enum = $unique; diff --git a/tests/Support/SchemaRoundTrip.php b/tests/Support/SchemaRoundTrip.php index 55af9cc..3b1745c 100644 --- a/tests/Support/SchemaRoundTrip.php +++ b/tests/Support/SchemaRoundTrip.php @@ -24,7 +24,7 @@ final class SchemaRoundTrip public static function assertSourceSubset(array $source, array $output, string $path = 'root'): void { foreach ($source as $key => $value) { - if (in_array($key, self::IGNORED_KEYS, true)) { + if (in_array($key, self::IGNORED_KEYS, strict: true)) { continue; } @@ -39,8 +39,8 @@ public static function assertSourceSubset(array $source, array $output, string $ $outputValue = $output[$key]; if (is_array($value) && is_array($outputValue)) { - if (self::isList($value)) { - if (! self::isList($outputValue)) { + if (array_is_list($value)) { + if (! array_is_list($outputValue)) { throw new ExpectationFailedException( sprintf('Expected list at path [%s], got associative array.', $currentPath), ); @@ -87,15 +87,7 @@ public static function assertSourceSubset(array $source, array $output, string $ continue; } - if ($value !== $outputValue) { - if (is_int($value) && is_float($outputValue) && $value === (int) $outputValue) { - continue; - } - - if (is_float($value) && is_int($outputValue) && $value === (float) $outputValue) { - continue; - } - + if ($value !== $outputValue && ! self::areNumericEqual($value, $outputValue)) { throw new ExpectationFailedException( sprintf( 'Expected value %s at path [%s], got %s.', @@ -109,14 +101,11 @@ public static function assertSourceSubset(array $source, array $output, string $ } /** - * @param array $array + * Check whether two values are numerically equal across int/float boundaries. */ - private static function isList(array $array): bool + private static function areNumericEqual(mixed $a, mixed $b): bool { - if ($array === []) { - return true; - } - - return array_keys($array) === range(0, count($array) - 1); + return (is_int($a) && is_float($b) && $a === (int) $b) + || (is_float($a) && is_int($b) && $a === (float) $b); } } From 6555bb32a20915e6c7c69043489d82beceb7eaed Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 11 Jun 2026 09:09:35 +0100 Subject: [PATCH 3/6] :art: --- src/Converters/JsonConverter.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php index 036ab1b..7b15523 100644 --- a/src/Converters/JsonConverter.php +++ b/src/Converters/JsonConverter.php @@ -171,8 +171,9 @@ private function getBoolOrSchema(string $key): bool|JsonSchema|null */ private function inferTypeFromKeywords(): ?string { - $stringKeywords = array_flip([ - 'pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType'], + $stringKeywords = array_flip( + [ + 'pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType'], ); if (array_intersect_key($this->data, $stringKeywords) !== []) { @@ -345,6 +346,7 @@ private function applyDefinitions(AbstractSchema $schema): void if (! is_array($definitionData)) { continue; } + $schema->addDefinition($name, (new self($definitionData, $this->schemaVersion))->convert()); } } @@ -368,6 +370,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($propertyData)) { continue; } + $propertySchemas[$name] = (new self($propertyData, $this->schemaVersion))->convert(); if (in_array($name, $required, true)) { @@ -396,6 +399,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($propertyData)) { continue; } + $objectSchema->patternProperty($pattern, (new self($propertyData, $this->schemaVersion))->convert()); } } @@ -421,6 +425,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($dependentData)) { continue; } + $objectSchema->dependentSchema($property, (new self($dependentData, $this->schemaVersion))->convert()); } } @@ -437,6 +442,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): vo if (! is_array($requiredProperties)) { continue; } + $normalized[$property] = array_values(array_filter($requiredProperties, is_string(...))); } From b0ba1b8a0493868123eef61df8ac993870cfcbba Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 11 Jun 2026 09:22:23 +0100 Subject: [PATCH 4/6] docs --- .../json-schema/advanced/definitions-refs.mdx | 63 ++++++++++++++++ .../advanced/dependent-schemas.mdx | 72 +++++++++++++++++++ .../json-schema/code-generation/from-json.mdx | 46 +++++++++++- docs/json-schema/introduction.mdx | 9 ++- docs/json-schema/schema-types/array.mdx | 60 ++++++++++++++++ docs/json-schema/schema-types/object.mdx | 16 +++++ docs/json-schema/schema-types/union.mdx | 63 ++++++++++++++++ src/Schema.php | 8 +++ tests/Unit/Types/UnionSchemaTest.php | 35 +++++++++ 9 files changed, 367 insertions(+), 5 deletions(-) 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..5c4d708 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 typeless schemas — 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 [Union Schema](/json-schema/schema-types/union) 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..018615b 100644 --- a/docs/json-schema/introduction.mdx +++ b/docs/json-schema/introduction.mdx @@ -137,10 +137,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 +171,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/union.mdx b/docs/json-schema/schema-types/union.mdx index cfdfbcc..be4f6c6 100644 --- a/docs/json-schema/schema-types/union.mdx +++ b/docs/json-schema/schema-types/union.mdx @@ -110,6 +110,69 @@ $responseDataSchema = Schema::union([ ->description('API response data in various formats'); ``` +## Typeless Schemas + +Sometimes you need a schema document that has **no** `type` keyword at all — for example a composition-only schema (just `allOf`/`anyOf`/`oneOf`) or a definition-only document (just `$defs`). Use `Schema::typeless()` to build one: + +```php +use Cortex\JsonSchema\Schema; + +// Composition-only schema with no "type" keyword +$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"] + } + ] +} +``` + + +This is also useful for definition-only documents: + +```php +$definitions = Schema::typeless() + ->addDefinition('address', Schema::object()->properties( + Schema::string('street')->required(), + Schema::string('city')->required() + )); +// Output contains "$defs" but no "type" +``` + + +`Schema::typeless()` is a thin wrapper around `UnionSchema::typeless()`, which is in turn built on top of the `omitType()` method available on any schema (it tells the schema to drop the `type` keyword when converting to an array). Importing a typeless JSON Schema via [`Schema::fromJson()` / `Schema::fromArray()`](/json-schema/code-generation/from-json) produces the same result automatically. + + ## Union with Specific Schemas Use `oneOf` to define specific schemas for each type: diff --git a/src/Schema.php b/src/Schema.php index 871bb80..88508ab 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -99,6 +99,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): UnionSchema + { + return UnionSchema::typeless($title, $schemaVersion ?? self::getDefaultVersion()); + } + /** * Create a schema from a given closure. */ diff --git a/tests/Unit/Types/UnionSchemaTest.php b/tests/Unit/Types/UnionSchemaTest.php index 69d3b60..05e6bf2 100644 --- a/tests/Unit/Types/UnionSchemaTest.php +++ b/tests/Unit/Types/UnionSchemaTest.php @@ -93,6 +93,41 @@ expect($schemaArray)->toHaveKey('readOnly', true); }); +it('can create a typeless schema via the Schema facade', function (): void { + $unionSchema = 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(), + ), + ); + + expect($unionSchema)->toBeInstanceOf(UnionSchema::class); + + $schemaArray = $unionSchema->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 { + $unionSchema = UnionSchema::typeless() + ->addDefinition('address', Schema::object()->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), + )); + + $schemaArray = $unionSchema->toArray(); + + expect($schemaArray)->not->toHaveKey('type'); + expect($schemaArray['$defs']['address']['type'])->toBe('object'); +}); + it('throws exception when creating union schema with no types', function (): void { expect(fn(): UnionSchema => Schema::union([], 'empty'))->toThrow( SchemaException::class, From 714732e26ef0a83c9d387c26c8702d06a74e6ef7 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 11 Jun 2026 09:33:59 +0100 Subject: [PATCH 5/6] cleanup --- .../json-schema/code-generation/from-json.mdx | 2 +- docs/json-schema/schema-types/union.mdx | 2 +- src/Converters/JsonConverter.php | 15 +++--- src/Schema.php | 5 +- src/Types/AbstractSchema.php | 20 ++----- src/Types/TypelessSchema.php | 39 ++++++++++++++ src/Types/UnionSchema.php | 11 ---- tests/Unit/Converters/JsonConverterTest.php | 3 +- tests/Unit/Types/TypelessSchemaTest.php | 52 +++++++++++++++++++ tests/Unit/Types/UnionSchemaTest.php | 35 ------------- 10 files changed, 110 insertions(+), 74 deletions(-) create mode 100644 src/Types/TypelessSchema.php create mode 100644 tests/Unit/Types/TypelessSchemaTest.php diff --git a/docs/json-schema/code-generation/from-json.mdx b/docs/json-schema/code-generation/from-json.mdx index 5c4d708..7ed8c6b 100644 --- a/docs/json-schema/code-generation/from-json.mdx +++ b/docs/json-schema/code-generation/from-json.mdx @@ -259,7 +259,7 @@ Version-specific keywords are imported using the detected (or supplied) `SchemaV ### Typeless Schemas -Schemas without a `type` keyword that carry structural keywords (such as `$ref`, `$defs`, `allOf`, `properties`, or `required`) are imported as typeless schemas — the converter does **not** expand them into a union of all types. This keeps composition-only and definition-only documents intact: +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" diff --git a/docs/json-schema/schema-types/union.mdx b/docs/json-schema/schema-types/union.mdx index be4f6c6..8dd6771 100644 --- a/docs/json-schema/schema-types/union.mdx +++ b/docs/json-schema/schema-types/union.mdx @@ -170,7 +170,7 @@ $definitions = Schema::typeless() ``` -`Schema::typeless()` is a thin wrapper around `UnionSchema::typeless()`, which is in turn built on top of the `omitType()` method available on any schema (it tells the schema to drop the `type` keyword when converting to an array). Importing a typeless JSON Schema via [`Schema::fromJson()` / `Schema::fromArray()`](/json-schema/code-generation/from-json) produces the same result automatically. +`Schema::typeless()` returns a `TypelessSchema` — a dedicated schema type with no `type` keyword, designed for composition-only and definition-only documents. Importing a typeless JSON Schema via [`Schema::fromJson()` / `Schema::fromArray()`](/json-schema/code-generation/from-json) also produces a `TypelessSchema` automatically. ## Union with Specific Schemas diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php index 7b15523..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 @@ -354,7 +355,7 @@ private function applyDefinitions(AbstractSchema $schema): void /** * Apply object-specific keywords. */ - private function applyObjectKeywords(ObjectSchema|UnionSchema $objectSchema): void + private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $objectSchema): void { $required = $this->getArray('required') ?? []; @@ -555,7 +556,7 @@ private function applyNumericKeywords(AbstractSchema $schema): void return; } - if (! $schema instanceof NumberSchema && ! $schema instanceof UnionSchema) { + if (! $schema instanceof NumberSchema && ! $schema instanceof UnionSchema && ! $schema instanceof TypelessSchema) { return; } @@ -625,18 +626,18 @@ private function getArrayOfSchemas(string $key): array )); } - private function createTypelessSchema(?string $title): UnionSchema + private function createTypelessSchema(?string $title): TypelessSchema { - $unionSchema = UnionSchema::typeless($title, $this->schemaVersion); - $this->applyCommonKeywords($unionSchema); + $typelessSchema = new TypelessSchema($title, $this->schemaVersion); + $this->applyCommonKeywords($typelessSchema); $objectKeywords = array_flip(['properties', 'patternProperties', 'required']); if (array_intersect_key($this->data, $objectKeywords) !== []) { - $this->applyObjectKeywords($unionSchema); + $this->applyObjectKeywords($typelessSchema); } - return $unionSchema; + return $typelessSchema; } private function createStringSchema(?string $title): StringSchema diff --git a/src/Schema.php b/src/Schema.php index 88508ab..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; @@ -102,9 +103,9 @@ public static function mixed(?string $title = null, ?SchemaVersion $schemaVersio /** * Create a typeless schema for composition-only or definition-only documents. */ - public static function typeless(?string $title = null, ?SchemaVersion $schemaVersion = null): UnionSchema + public static function typeless(?string $title = null, ?SchemaVersion $schemaVersion = null): TypelessSchema { - return UnionSchema::typeless($title, $schemaVersion ?? self::getDefaultVersion()); + return new TypelessSchema($title, $schemaVersion ?? self::getDefaultVersion()); } /** diff --git a/src/Types/AbstractSchema.php b/src/Types/AbstractSchema.php index 5e97ae1..92dfdc4 100644 --- a/src/Types/AbstractSchema.php +++ b/src/Types/AbstractSchema.php @@ -45,13 +45,11 @@ abstract class AbstractSchema implements JsonSchema protected SchemaVersion $schemaVersion = SchemaVersion::Draft_2020_12; - protected bool $omitType = false; - /** - * @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, ) { @@ -78,22 +76,12 @@ public function getVersion(): SchemaVersion return $this->schemaVersion; } - /** - * Omit the type keyword when converting to array. - */ - public function omitType(bool $omit = true): static - { - $this->omitType = $omit; - - return $this; - } - /** * Add null type to schema. */ public function nullable(): static { - if ($this->isNullable()) { + if ($this->type === null || $this->isNullable()) { return $this; } @@ -121,7 +109,7 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true $schema = []; - if (! $this->omitType) { + if ($this->type !== null) { $schema['type'] = is_array($this->type) ? array_map(static fn(SchemaType $schemaType) => $schemaType->value, $this->type) : $this->type->value; 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/src/Types/UnionSchema.php b/src/Types/UnionSchema.php index 73ec804..d08052f 100644 --- a/src/Types/UnionSchema.php +++ b/src/Types/UnionSchema.php @@ -58,15 +58,4 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true return $this->addPropertiesToSchema($schema); } - - /** - * Create a typeless schema for composition-only or definition-only documents. - */ - public static function typeless(?string $title = null, ?SchemaVersion $schemaVersion = null): self - { - $schema = new self([SchemaType::String], $title, $schemaVersion); - $schema->omitType(); - - return $schema; - } } diff --git a/tests/Unit/Converters/JsonConverterTest.php b/tests/Unit/Converters/JsonConverterTest.php index e28e624..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; @@ -630,7 +631,7 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(UnionSchema::class); + expect($jsonSchema)->toBeInstanceOf(TypelessSchema::class); $output = $jsonSchema->toArray(includeSchemaRef: false); expect($output)->not->toHaveKey('type'); 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'); +}); diff --git a/tests/Unit/Types/UnionSchemaTest.php b/tests/Unit/Types/UnionSchemaTest.php index 05e6bf2..69d3b60 100644 --- a/tests/Unit/Types/UnionSchemaTest.php +++ b/tests/Unit/Types/UnionSchemaTest.php @@ -93,41 +93,6 @@ expect($schemaArray)->toHaveKey('readOnly', true); }); -it('can create a typeless schema via the Schema facade', function (): void { - $unionSchema = 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(), - ), - ); - - expect($unionSchema)->toBeInstanceOf(UnionSchema::class); - - $schemaArray = $unionSchema->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 { - $unionSchema = UnionSchema::typeless() - ->addDefinition('address', Schema::object()->properties( - Schema::string('street')->required(), - Schema::string('city')->required(), - )); - - $schemaArray = $unionSchema->toArray(); - - expect($schemaArray)->not->toHaveKey('type'); - expect($schemaArray['$defs']['address']['type'])->toBe('object'); -}); - it('throws exception when creating union schema with no types', function (): void { expect(fn(): UnionSchema => Schema::union([], 'empty'))->toThrow( SchemaException::class, From b10e2d104abd2eabb637312a6ef88f42fa7dbdf6 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 11 Jun 2026 09:35:42 +0100 Subject: [PATCH 6/6] docs --- docs/docs.json | 1 + .../json-schema/code-generation/from-json.mdx | 2 +- docs/json-schema/introduction.mdx | 1 + docs/json-schema/schema-types/typeless.mdx | 137 ++++++++++++++++++ docs/json-schema/schema-types/union.mdx | 65 +-------- 5 files changed, 143 insertions(+), 63 deletions(-) create mode 100644 docs/json-schema/schema-types/typeless.mdx 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/code-generation/from-json.mdx b/docs/json-schema/code-generation/from-json.mdx index 7ed8c6b..db4a435 100644 --- a/docs/json-schema/code-generation/from-json.mdx +++ b/docs/json-schema/code-generation/from-json.mdx @@ -277,7 +277,7 @@ $schema = Schema::fromArray([ $schema->toArray(); // No "type" key is emitted ``` -See [Union Schema](/json-schema/schema-types/union) for building typeless schemas with the fluent builder via `Schema::typeless()`. +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 018615b..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 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 8dd6771..d8cedff 100644 --- a/docs/json-schema/schema-types/union.mdx +++ b/docs/json-schema/schema-types/union.mdx @@ -110,68 +110,9 @@ $responseDataSchema = Schema::union([ ->description('API response data in various formats'); ``` -## Typeless Schemas - -Sometimes you need a schema document that has **no** `type` keyword at all — for example a composition-only schema (just `allOf`/`anyOf`/`oneOf`) or a definition-only document (just `$defs`). Use `Schema::typeless()` to build one: - -```php -use Cortex\JsonSchema\Schema; - -// Composition-only schema with no "type" keyword -$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"] - } - ] -} -``` - - -This is also useful for definition-only documents: - -```php -$definitions = Schema::typeless() - ->addDefinition('address', Schema::object()->properties( - Schema::string('street')->required(), - Schema::string('city')->required() - )); -// Output contains "$defs" but no "type" -``` - - -`Schema::typeless()` returns a `TypelessSchema` — a dedicated schema type with no `type` keyword, designed for composition-only and definition-only documents. Importing a typeless JSON Schema via [`Schema::fromJson()` / `Schema::fromArray()`](/json-schema/code-generation/from-json) also produces a `TypelessSchema` automatically. - + +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