diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml new file mode 100644 index 0000000..71f8cbb --- /dev/null +++ b/.github/workflows/sync-docs.yml @@ -0,0 +1,19 @@ +name: Sync Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + +jobs: + notify-docs-repo: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.DOCS_TOKEN }} + repository: cortexphp/docs + event-type: docs-updated diff --git a/README.md b/README.md index d78a7c2..d5a5a7a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## Features - πŸ—οΈ **Fluent Builder API** - Build JSON Schemas using an intuitive fluent interface -- πŸ“ **Multi-Version Support** - Support for JSON Schema Draft-07, Draft 2019-09, and Draft 2020-12 +- πŸ“ **Multi-Version Support** - Support for JSON Schema Draft-06, Draft-07, Draft 2019-09, and Draft 2020-12 - βœ… **Validation** - Validate data against schemas with detailed error messages - 🀝 **Conditional Schemas** - Support for if/then/else, allOf, anyOf, and not conditions - πŸ”„ **Reflection** - Generate schemas from PHP Classes, Enums and Closures @@ -24,7 +24,8 @@ This package supports multiple JSON Schema specification versions with automatic - **Draft 2020-12** - (Default) Latest version with `prefixItems`, dynamic references, and format vocabularies - **Draft 2019-09** - Adds advanced features like `$defs`, `unevaluatedProperties`, `deprecated` -- **Draft-07** (2018) - Legacy version for maximum compatibility +- **Draft-07** (2018) - Legacy version with broad tool compatibility +- **Draft-06** (2017) - Legacy version for maximum compatibility with older tooling ## Requirements diff --git a/docs/json-schema/advanced/string-formats.mdx b/docs/json-schema/advanced/string-formats.mdx index 1b1b161..a3de1a8 100644 --- a/docs/json-schema/advanced/string-formats.mdx +++ b/docs/json-schema/advanced/string-formats.mdx @@ -173,27 +173,28 @@ $userProfileSchema->isValid($validProfile); // true ## Format Support by Version -| Format | Draft-07 | Draft 2019-09 | Draft 2020-12 | Description | -|--------|----------|---------------|---------------|-------------| -| `Email` | βœ… | βœ… | βœ… | Standard email address | -| `Uri` | βœ… | βœ… | βœ… | URI reference | -| `UriReference` | βœ… | βœ… | βœ… | URI reference (relative or absolute) | -| `Hostname` | βœ… | βœ… | βœ… | Internet hostname | -| `Ipv4` | βœ… | βœ… | βœ… | IPv4 address | -| `Ipv6` | βœ… | βœ… | βœ… | IPv6 address | -| `Date` | βœ… | βœ… | βœ… | Full date | -| `Time` | βœ… | βœ… | βœ… | Time | -| `DateTime` | βœ… | βœ… | βœ… | Date and time | -| `JsonPointer` | βœ… | βœ… | βœ… | JSON Pointer | -| `RelativeJsonPointer` | βœ… | βœ… | βœ… | Relative JSON Pointer | -| `UriTemplate` | βœ… | βœ… | βœ… | URI Template | -| `Regex` | βœ… | βœ… | βœ… | Regular expression pattern | +| Format | Draft-06 | Draft-07 | Draft 2019-09 | Draft 2020-12 | Description | +|--------|----------|----------|---------------|---------------|-------------| +| `Email` | βœ… | βœ… | βœ… | βœ… | Standard email address | +| `Uri` | βœ… | βœ… | βœ… | βœ… | URI reference | +| `UriReference` | βœ… | βœ… | βœ… | βœ… | URI reference (relative or absolute) | +| `Hostname` | βœ… | βœ… | βœ… | βœ… | Internet hostname | +| `Ipv4` | βœ… | βœ… | βœ… | βœ… | IPv4 address | +| `Ipv6` | βœ… | βœ… | βœ… | βœ… | IPv6 address | +| `Date` | ❌ | βœ… | βœ… | βœ… | Full date | +| `Time` | ❌ | βœ… | βœ… | βœ… | Time | +| `DateTime` | βœ… | βœ… | βœ… | βœ… | Date and time | +| `JsonPointer` | βœ… | βœ… | βœ… | βœ… | JSON Pointer | +| `RelativeJsonPointer` | ❌ | βœ… | βœ… | βœ… | Relative JSON Pointer | +| `UriTemplate` | βœ… | βœ… | βœ… | βœ… | URI Template | +| `Regex` | ❌ | βœ… | βœ… | βœ… | Regular expression pattern | +| `IdnEmail` | ❌ | βœ… | βœ… | βœ… | Internationalized email | +| `IdnHostname` | ❌ | βœ… | βœ… | βœ… | Internationalized hostname | +| `Iri` | ❌ | βœ… | βœ… | βœ… | Internationalized URI | +| `IriReference` | ❌ | βœ… | βœ… | βœ… | IRI reference | | **Draft 2019-09+** | -| `Duration` | ❌ | βœ… | βœ… | ISO 8601 duration | -| `Uuid` | ❌ | βœ… | βœ… | UUID string | -| `IdnEmail` | ❌ | βœ… | βœ… | Internationalized email | -| `Iri` | ❌ | βœ… | βœ… | Internationalized URI | -| `IriReference` | ❌ | βœ… | βœ… | IRI reference | +| `Duration` | ❌ | ❌ | βœ… | βœ… | ISO 8601 duration | +| `Uuid` | ❌ | ❌ | βœ… | βœ… | UUID string | ## Common Use Cases diff --git a/docs/json-schema/installation.mdx b/docs/json-schema/installation.mdx index 32ffb32..ded5a73 100644 --- a/docs/json-schema/installation.mdx +++ b/docs/json-schema/installation.mdx @@ -26,7 +26,7 @@ icon: 'terminal' use Cortex\JsonSchema\Enums\SchemaVersion; // Override default version (if needed) - Schema::setDefaultVersion(SchemaVersion::Draft_07); // For maximum compatibility + Schema::setDefaultVersion(SchemaVersion::Draft_06); // For maximum compatibility // or Schema::setDefaultVersion(SchemaVersion::Draft_2019_09); // For balanced features ``` diff --git a/docs/json-schema/introduction.mdx b/docs/json-schema/introduction.mdx index a370416..09fc1bf 100644 --- a/docs/json-schema/introduction.mdx +++ b/docs/json-schema/introduction.mdx @@ -23,7 +23,7 @@ icon: 'book-open' href="/json-schema/versions" icon="layers" > - Support for JSON Schema Draft-07, Draft 2019-09, and Draft 2020-12 with automatic feature validation. + Support for JSON Schema Draft-06, Draft-07, Draft 2019-09, and Draft 2020-12 with automatic feature validation. -The package defaults to Draft 2020-12 for the latest features. Use Draft-07 for maximum compatibility with older tools, or Draft 2019-09 for a balance of features and compatibility. +The package defaults to Draft 2020-12 for the latest features. Use Draft-06 for maximum compatibility with older tools, or Draft 2019-09 for a balance of features and compatibility. -| Feature | Draft-07 | Draft 2019-09 | Draft 2020-12 | -|---------|----------|---------------|---------------| -| Basic validation | βœ… | βœ… | βœ… | -| Conditionals | βœ… | βœ… | βœ… | -| Basic formats | βœ… | βœ… | βœ… | -| `deprecated` | ❌ | βœ… | βœ… | -| `$defs` | ❌ | βœ… | βœ… | -| `minContains`/`maxContains` | ❌ | βœ… | βœ… | -| `unevaluatedProperties` | ❌ | βœ… | βœ… | -| `dependentSchemas` | ❌ | βœ… | βœ… | -| `prefixItems` | ❌ | ❌ | βœ… | +| Feature | Draft-06 | Draft-07 | Draft 2019-09 | Draft 2020-12 | +|---------|----------|----------|---------------|---------------| +| Basic validation | βœ… | βœ… | βœ… | βœ… | +| Conditionals | ❌ | βœ… | βœ… | βœ… | +| Basic formats | βœ… | βœ… | βœ… | βœ… | +| `deprecated` | ❌ | ❌ | βœ… | βœ… | +| `$defs` | ❌ | ❌ | βœ… | βœ… | +| `minContains`/`maxContains` | ❌ | ❌ | βœ… | βœ… | +| `unevaluatedProperties` | ❌ | ❌ | βœ… | βœ… | +| `dependentSchemas` | ❌ | ❌ | βœ… | βœ… | +| `prefixItems` | ❌ | ❌ | ❌ | βœ… | diff --git a/docs/json-schema/quickstart.mdx b/docs/json-schema/quickstart.mdx index 03e7818..ab2d3a1 100644 --- a/docs/json-schema/quickstart.mdx +++ b/docs/json-schema/quickstart.mdx @@ -258,7 +258,7 @@ $modernSchema = Schema::string('field') ->comment('Use newField instead'); // Override default version if needed -Schema::setDefaultVersion(SchemaVersion::Draft_07); // For maximum compatibility +Schema::setDefaultVersion(SchemaVersion::Draft_06); // For maximum compatibility ``` ## Common Validation Patterns @@ -322,7 +322,7 @@ Schema::setDefaultVersion(SchemaVersion::Draft_07); // For maximum compatibility ->comment('Use new_field instead'); // Override default version if needed for compatibility - Schema::setDefaultVersion(SchemaVersion::Draft_07); + Schema::setDefaultVersion(SchemaVersion::Draft_06); ``` diff --git a/docs/json-schema/schema-types/string.mdx b/docs/json-schema/schema-types/string.mdx index f37e84b..0e543d3 100644 --- a/docs/json-schema/schema-types/string.mdx +++ b/docs/json-schema/schema-types/string.mdx @@ -129,23 +129,73 @@ $timeSchema = Schema::string('appointment_time') | `Hostname` | Internet hostname | `example.com` | | `Ipv4` | IPv4 address | `192.168.1.1` | | `Ipv6` | IPv6 address | `2001:db8::1` | -| `Date` | Full date (RFC 3339) | `2023-12-25` | -| `Time` | Time (RFC 3339) | `14:30:00` | +| `Date`† | Full date (RFC 3339) | `2023-12-25` | +| `Time`† | Time (RFC 3339) | `14:30:00` | | `DateTime` | Date-time (RFC 3339) | `2023-12-25T14:30:00Z` | | `Duration` | Duration (ISO 8601)* | `P1Y2M3DT4H5M6S` | | `Uuid` | UUID string* | `123e4567-e89b-12d3-a456-426614174000` | | `JsonPointer` | JSON Pointer | `/users/123/name` | -| `RelativeJsonPointer` | Relative JSON Pointer | `1/name` | +| `RelativeJsonPointer`† | Relative JSON Pointer | `1/name` | | `UriTemplate` | URI Template | `/users/{id}` | -| `IdnEmail` | Internationalized email | `η”¨ζˆ·@example.com` | -| `IdnHostname` | Internationalized hostname | `δΎ‹γˆ.γƒ†γ‚Ήγƒˆ` | -| `Iri` | Internationalized URI | `https://δΎ‹γˆ.γƒ†γ‚Ήγƒˆ` | -| `IriReference` | IRI reference | `//δΎ‹γˆ.γƒ†γ‚Ήγƒˆ/path` | +| `Regex`† | Regular expression pattern | `^[a-zA-Z0-9_]+$` | +| `IdnEmail`† | Internationalized email | `η”¨ζˆ·@example.com` | +| `IdnHostname`† | Internationalized hostname | `δΎ‹γˆ.γƒ†γ‚Ήγƒˆ` | +| `Iri`† | Internationalized URI | `https://δΎ‹γˆ.γƒ†γ‚Ήγƒˆ` | +| `IriReference`† | IRI reference | `//δΎ‹γˆ.γƒ†γ‚Ήγƒˆ/path` | -Formats marked with * require JSON Schema Draft 2019-09 or later. +Formats marked with † require JSON Schema Draft-07 or later. Formats marked with * require Draft 2019-09 or later. +## Content Annotations + +Describe encoded content carried by a string using `contentEncoding` and `contentMediaType`, and (optionally) validate the decoded content with `contentSchema`: + +```php +use Cortex\JsonSchema\Enums\SchemaVersion; +use Cortex\JsonSchema\Enums\SchemaFormat; + +$payloadSchema = Schema::string('payload', SchemaVersion::Draft_2019_09) + ->contentEncoding('base64') + ->contentMediaType('application/json') + ->contentSchema( + Schema::object() + ->properties( + Schema::string('event_id')->required(), + Schema::string('type')->required(), + Schema::string('created_at')->format(SchemaFormat::DateTime)->required(), + Schema::object('data') + ->properties( + Schema::string('user_id')->required(), + Schema::string('email')->format(SchemaFormat::Email), + ) + ->required(), + ), + ); +``` + + +`contentSchema` requires JSON Schema Draft 2019-09 or later. `contentEncoding` and `contentMediaType` are available in all supported versions. + + +Validation examples: + +```php +$payloadSchema->isValid(base64_encode( + json_encode([ + 'event_id' => 'evt_123', + 'type' => 'user.created', + 'created_at' => '2024-03-14T12:00:00Z', + 'data' => [ + 'user_id' => 'usr_1', + 'email' => 'ada@example.com', + ], + ], JSON_THROW_ON_ERROR), +)); // true (base64 encoded string value and matches the content schema) + +$payloadSchema->isValid(123); // false (not a string) +``` + ## Enumeration Values Restrict strings to a specific set of allowed values: @@ -195,6 +245,10 @@ $passwordSchema = Schema::string('password') ->pattern('(?=.*[a-z])(?=.*[A-Z])(?=.*\d)'); ``` + +Read-only and write-only annotations require JSON Schema Draft-07 or later. + + ## Complex Example Here's a comprehensive example combining multiple string validation features: diff --git a/docs/json-schema/versions.mdx b/docs/json-schema/versions.mdx index 78c6b3c..eb9b437 100644 --- a/docs/json-schema/versions.mdx +++ b/docs/json-schema/versions.mdx @@ -9,8 +9,11 @@ icon: 'history' This package supports multiple JSON Schema specification versions with automatic feature validation to ensure compatibility. + + Legacy version for maximum compatibility with older tools. Includes core validation features and basic constraints. + - Legacy version for maximum compatibility across tools and libraries. Includes core validation features and basic conditionals. + Legacy version with broad compatibility across tools and libraries. Includes core validation features and conditionals. Adds advanced features like `$defs`, `unevaluatedProperties`, `deprecated`, and enhanced array validation. @@ -96,28 +99,28 @@ $arraySchema = Schema::array('items', SchemaVersion::Draft_07) ## Feature Support Matrix -| Feature | Draft-07 | Draft 2019-09 | Draft 2020-12 | Description | -|---------|----------|---------------|---------------|-------------| +| Feature | Draft-06 | Draft-07 | Draft 2019-09 | Draft 2020-12 | Description | +|---------|----------|----------|---------------|---------------|-------------| | **Core Validation** | -| `minLength`, `maxLength`, `pattern` | βœ… | βœ… | βœ… | Basic string validation | -| `minimum`, `maximum`, `multipleOf` | βœ… | βœ… | βœ… | Numeric constraints | -| `minItems`, `maxItems`, `uniqueItems` | βœ… | βœ… | βœ… | Array validation | -| `properties`, `required`, `additionalProperties` | βœ… | βœ… | βœ… | Object validation | +| `minLength`, `maxLength`, `pattern` | βœ… | βœ… | βœ… | βœ… | Basic string validation | +| `minimum`, `maximum`, `multipleOf` | βœ… | βœ… | βœ… | βœ… | Numeric constraints | +| `minItems`, `maxItems`, `uniqueItems` | βœ… | βœ… | βœ… | βœ… | Array validation | +| `properties`, `required`, `additionalProperties` | βœ… | βœ… | βœ… | βœ… | Object validation | | **Conditionals** | -| `if`/`then`/`else` | βœ… | βœ… | βœ… | Conditional schemas | -| `allOf`, `anyOf`, `oneOf`, `not` | βœ… | βœ… | βœ… | Logical combinations | +| `if`/`then`/`else` | ❌ | βœ… | βœ… | βœ… | Conditional schemas | +| `allOf`, `anyOf`, `oneOf`, `not` | βœ… | βœ… | βœ… | βœ… | Logical combinations | | **Advanced Features** | -| `deprecated` | ❌ | βœ… | βœ… | Property deprecation | -| `$defs` (replaces `definitions`) | ❌ | βœ… | βœ… | Schema definitions | -| `minContains`, `maxContains` | ❌ | βœ… | βœ… | Array contains validation | -| `unevaluatedProperties`, `unevaluatedItems` | ❌ | βœ… | βœ… | Strict validation | -| `dependentSchemas` | ❌ | βœ… | βœ… | Property dependencies | +| `deprecated` | ❌ | ❌ | βœ… | βœ… | Property deprecation | +| `$defs` (replaces `definitions`) | ❌ | ❌ | βœ… | βœ… | Schema definitions | +| `minContains`, `maxContains` | ❌ | ❌ | βœ… | βœ… | Array contains validation | +| `unevaluatedProperties`, `unevaluatedItems` | ❌ | ❌ | βœ… | βœ… | Strict validation | +| `dependentSchemas` | ❌ | ❌ | βœ… | βœ… | Property dependencies | | **Draft 2020-12 Only** | -| `prefixItems` | ❌ | ❌ | βœ… | Tuple validation | -| Dynamic references (`$dynamicRef`) | ❌ | ❌ | βœ… | Dynamic schema refs | +| `prefixItems` | ❌ | ❌ | ❌ | βœ… | Tuple validation | +| Dynamic references (`$dynamicRef`) | ❌ | ❌ | ❌ | βœ… | Dynamic schema refs | | **Formats** | -| `email`, `uri`, `date-time` | βœ… | βœ… | βœ… | Basic formats | -| `duration`, `uuid` | ❌ | βœ… | βœ… | Extended formats | +| `email`, `uri`, `date-time` | βœ… | βœ… | βœ… | βœ… | Basic formats | +| `duration`, `uuid` | ❌ | ❌ | βœ… | βœ… | Extended formats | ## Version-Appropriate Output @@ -181,5 +184,5 @@ echo $feature->getMinimumVersion()->name; // "Draft201909" ``` -The package defaults to Draft 2020-12 for the latest features. Use Draft-07 for maximum compatibility with older tools if needed. +The package defaults to Draft 2020-12 for the latest features. Use Draft-06 for maximum compatibility with older tools if needed. diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php index ec31167..272a5a6 100644 --- a/src/Converters/JsonConverter.php +++ b/src/Converters/JsonConverter.php @@ -18,6 +18,7 @@ use Cortex\JsonSchema\Types\BooleanSchema; use Cortex\JsonSchema\Types\IntegerSchema; use Cortex\JsonSchema\Contracts\JsonSchema; +use Cortex\JsonSchema\Types\AbstractSchema; use Cortex\JsonSchema\Exceptions\SchemaException; class JsonConverter implements Converter @@ -149,12 +150,23 @@ private function getConstValue(string $key): bool|float|int|string|null return null; } + /** + * Apply shared fields to the schema. + */ + private function applyId(AbstractSchema $schema): void + { + if (($id = $this->getString('$id')) !== null) { + $schema->id($id); + } + } + /** * Detect schema version from $schema URI. */ 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, @@ -165,6 +177,7 @@ private function detectSchemaVersion(string $schemaUri): SchemaVersion private function createStringSchema(?string $title): StringSchema { $stringSchema = new StringSchema($title, $this->schemaVersion); + $this->applyId($stringSchema); if (($minLength = $this->getInt('minLength')) !== null) { $stringSchema->minLength($minLength); @@ -178,6 +191,23 @@ private function createStringSchema(?string $title): StringSchema $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); + } + if (($format = $this->getString('format')) !== null) { $stringSchema->format($format); } @@ -217,6 +247,7 @@ private function createStringSchema(?string $title): StringSchema private function createNumberSchema(?string $title): NumberSchema { $numberSchema = new NumberSchema($title, $this->schemaVersion); + $this->applyId($numberSchema); if (($minimum = $this->getFloat('minimum')) !== null) { $numberSchema->minimum($minimum); @@ -261,6 +292,7 @@ private function createNumberSchema(?string $title): NumberSchema private function createIntegerSchema(?string $title): IntegerSchema { $integerSchema = new IntegerSchema($title, $this->schemaVersion); + $this->applyId($integerSchema); if (($minimum = $this->getInt('minimum')) !== null) { $integerSchema->minimum($minimum); @@ -305,6 +337,7 @@ private function createIntegerSchema(?string $title): IntegerSchema private function createBooleanSchema(?string $title): BooleanSchema { $booleanSchema = new BooleanSchema($title, $this->schemaVersion); + $this->applyId($booleanSchema); if (($const = $this->getConstValue('const')) !== null) { $booleanSchema->const($const); @@ -328,6 +361,7 @@ private function createBooleanSchema(?string $title): BooleanSchema private function createArraySchema(?string $title): ArraySchema { $arraySchema = new ArraySchema($title, $this->schemaVersion); + $this->applyId($arraySchema); if (($items = $this->getArray('items')) !== null) { $converter = new self($items, $this->schemaVersion); @@ -371,6 +405,7 @@ private function createArraySchema(?string $title): ArraySchema private function createObjectSchema(?string $title): ObjectSchema { $objectSchema = new ObjectSchema($title, $this->schemaVersion); + $this->applyId($objectSchema); $required = $this->getArray('required') ?? []; if (($properties = $this->getArray('properties')) !== null) { @@ -433,6 +468,7 @@ private function createObjectSchema(?string $title): 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); @@ -460,6 +496,8 @@ private function createUnionSchema(?string $title): UnionSchema $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); diff --git a/src/Enums/SchemaFeature.php b/src/Enums/SchemaFeature.php index 378c5dc..fb46618 100644 --- a/src/Enums/SchemaFeature.php +++ b/src/Enums/SchemaFeature.php @@ -9,7 +9,7 @@ */ enum SchemaFeature: string { - // Draft 07 features (available in all supported versions) + // Draft 07 features case If = 'if'; case Then = 'then'; case Else = 'else'; @@ -18,6 +18,15 @@ enum SchemaFeature: string case ContentEncoding = 'contentEncoding'; case WriteOnly = 'writeOnly'; case ReadOnly = 'readOnly'; + case Comment = '$comment'; + case FormatDate = 'format-date'; + case FormatTime = 'format-time'; + case FormatRegex = 'format-regex'; + case FormatRelativeJsonPointer = 'format-relative-json-pointer'; + case FormatIdnEmail = 'format-idn-email'; + case FormatIdnHostname = 'format-idn-hostname'; + case FormatIri = 'format-iri'; + case FormatIriReference = 'format-iri-reference'; // Draft 2019-09 new features case Anchor = '$anchor'; @@ -66,7 +75,16 @@ public function getMinimumVersion(): SchemaVersion self::ContentMediaType, self::ContentEncoding, self::WriteOnly, - self::ReadOnly => SchemaVersion::Draft_07, + self::ReadOnly, + self::Comment, + self::FormatDate, + self::FormatTime, + self::FormatRegex, + self::FormatRelativeJsonPointer, + self::FormatIdnEmail, + self::FormatIdnHostname, + self::FormatIri, + self::FormatIriReference => SchemaVersion::Draft_07, // Draft 2019-09 features self::Anchor, @@ -135,6 +153,15 @@ public function getDescription(): string self::ContentEncoding => 'Content encoding annotation', self::WriteOnly => 'Write-only property annotation', self::ReadOnly => 'Read-only property annotation', + self::Comment => 'Schema annotation comment', + self::FormatDate => 'RFC 3339 full-date format validation', + self::FormatTime => 'RFC 3339 time format validation', + self::FormatRegex => 'Regular expression format validation', + self::FormatRelativeJsonPointer => 'Relative JSON Pointer format validation', + self::FormatIdnEmail => 'Internationalized email format validation', + self::FormatIdnHostname => 'Internationalized hostname format validation', + self::FormatIri => 'Internationalized URI format validation', + self::FormatIriReference => 'Internationalized URI reference format validation', self::Anchor => 'Plain name anchors for schema identification', self::Defs => 'Schema definitions (renamed from definitions)', diff --git a/src/Enums/SchemaFormat.php b/src/Enums/SchemaFormat.php index 2383421..cb24bb5 100644 --- a/src/Enums/SchemaFormat.php +++ b/src/Enums/SchemaFormat.php @@ -18,6 +18,7 @@ enum SchemaFormat: string case UriTemplate = 'uri-template'; case Uuid = 'uuid'; case Hostname = 'hostname'; + case IdnHostname = 'idn-hostname'; case Ipv4 = 'ipv4'; case Ipv6 = 'ipv6'; case Iri = 'iri'; diff --git a/src/Enums/SchemaVersion.php b/src/Enums/SchemaVersion.php index 3333ecc..2953dde 100644 --- a/src/Enums/SchemaVersion.php +++ b/src/Enums/SchemaVersion.php @@ -6,6 +6,7 @@ enum SchemaVersion: string { + case Draft_06 = 'http://json-schema.org/draft-06/schema#'; case Draft_07 = 'http://json-schema.org/draft-07/schema#'; case Draft_2019_09 = 'https://json-schema.org/draft/2019-09/schema'; case Draft_2020_12 = 'https://json-schema.org/draft/2020-12/schema'; @@ -34,6 +35,7 @@ public static function default(): self public static function supported(): array { return [ + self::Draft_06, self::Draft_07, self::Draft_2019_09, self::Draft_2020_12, @@ -63,6 +65,7 @@ public function supports(SchemaFeature $schemaFeature): bool public function getName(): string { return match ($this) { + self::Draft_06 => 'Draft 6', self::Draft_07 => 'Draft 7', self::Draft_2019_09 => 'Draft 2019-09', self::Draft_2020_12 => 'Draft 2020-12', @@ -75,6 +78,7 @@ public function getName(): string public function getYear(): int { return match ($this) { + self::Draft_06 => 2017, self::Draft_07 => 2018, self::Draft_2019_09 => 2019, self::Draft_2020_12 => 2020, diff --git a/src/Types/AbstractSchema.php b/src/Types/AbstractSchema.php index 0b9331a..8e5abca 100644 --- a/src/Types/AbstractSchema.php +++ b/src/Types/AbstractSchema.php @@ -7,6 +7,7 @@ use Cortex\JsonSchema\Enums\SchemaType; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Contracts\JsonSchema; +use Cortex\JsonSchema\Types\Concerns\HasId; use Cortex\JsonSchema\Types\Concerns\HasRef; use Cortex\JsonSchema\Types\Concerns\HasEnum; use Cortex\JsonSchema\Types\Concerns\HasConst; @@ -25,6 +26,7 @@ abstract class AbstractSchema implements JsonSchema { use HasRef; + use HasId; use HasEnum; use HasConst; use HasTitle; @@ -113,6 +115,7 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true $schema['$schema'] = $this->schemaVersion->value; } + $schema = $this->addIdToSchema($schema); $schema = $this->addTitleToSchema($schema, $includeTitle); $schema = $this->addFormatToSchema($schema); $schema = $this->addDescriptionToSchema($schema); diff --git a/src/Types/Concerns/HasFormat.php b/src/Types/Concerns/HasFormat.php index 1d6dffa..844bf61 100644 --- a/src/Types/Concerns/HasFormat.php +++ b/src/Types/Concerns/HasFormat.php @@ -53,6 +53,14 @@ protected function validateFormatSupport(SchemaFormat $schemaFormat): void $feature = match ($schemaFormat) { SchemaFormat::Duration => SchemaFeature::FormatDuration, SchemaFormat::Uuid => SchemaFeature::FormatUuid, + SchemaFormat::Date => SchemaFeature::FormatDate, + SchemaFormat::Time => SchemaFeature::FormatTime, + SchemaFormat::Regex => SchemaFeature::FormatRegex, + SchemaFormat::RelativeJsonPointer => SchemaFeature::FormatRelativeJsonPointer, + SchemaFormat::IdnEmail => SchemaFeature::FormatIdnEmail, + SchemaFormat::IdnHostname => SchemaFeature::FormatIdnHostname, + SchemaFormat::Iri => SchemaFeature::FormatIri, + SchemaFormat::IriReference => SchemaFeature::FormatIriReference, // All other formats are available in all supported versions default => null, }; @@ -76,6 +84,30 @@ protected function getFormatFeatures(): array $features = []; switch ($this->format) { + case SchemaFormat::Date: + $features[] = SchemaFeature::FormatDate; + break; + case SchemaFormat::Time: + $features[] = SchemaFeature::FormatTime; + break; + case SchemaFormat::Regex: + $features[] = SchemaFeature::FormatRegex; + break; + case SchemaFormat::RelativeJsonPointer: + $features[] = SchemaFeature::FormatRelativeJsonPointer; + break; + case SchemaFormat::IdnEmail: + $features[] = SchemaFeature::FormatIdnEmail; + break; + case SchemaFormat::IdnHostname: + $features[] = SchemaFeature::FormatIdnHostname; + break; + case SchemaFormat::Iri: + $features[] = SchemaFeature::FormatIri; + break; + case SchemaFormat::IriReference: + $features[] = SchemaFeature::FormatIriReference; + break; case SchemaFormat::Duration: $features[] = SchemaFeature::FormatDuration; break; diff --git a/src/Types/Concerns/HasId.php b/src/Types/Concerns/HasId.php new file mode 100644 index 0000000..7cc969e --- /dev/null +++ b/src/Types/Concerns/HasId.php @@ -0,0 +1,45 @@ +id = $id; + + return $this; + } + + /** + * Get the $id value for this schema. + */ + public function getId(): ?string + { + return $this->id; + } + + /** + * Add $id to schema array. + * + * @param array $schema + * + * @return array + */ + protected function addIdToSchema(array $schema): array + { + if ($this->id !== null) { + $schema['$id'] = $this->id; + } + + return $schema; + } +} diff --git a/src/Types/Concerns/HasMetadata.php b/src/Types/Concerns/HasMetadata.php index 26b9867..80ae5ef 100644 --- a/src/Types/Concerns/HasMetadata.php +++ b/src/Types/Concerns/HasMetadata.php @@ -52,6 +52,7 @@ public function deprecated(bool $deprecated = true): static */ public function comment(string $comment): static { + $this->validateFeatureSupport(SchemaFeature::Comment); $this->comment = $comment; return $this; @@ -110,6 +111,10 @@ protected function getMetadataFeatures(): array $features[] = SchemaFeature::Deprecated; } + if ($this->comment !== null) { + $features[] = SchemaFeature::Comment; + } + return $features; } } diff --git a/src/Types/Concerns/HasReadWrite.php b/src/Types/Concerns/HasReadWrite.php index e5395fe..c95b692 100644 --- a/src/Types/Concerns/HasReadWrite.php +++ b/src/Types/Concerns/HasReadWrite.php @@ -18,6 +18,10 @@ trait HasReadWrite */ public function readOnly(bool $readOnly = true): static { + if ($readOnly) { + $this->validateFeatureSupport(SchemaFeature::ReadOnly); + } + $this->readOnly = $readOnly; return $this; @@ -28,6 +32,10 @@ public function readOnly(bool $readOnly = true): static */ public function writeOnly(bool $writeOnly = true): static { + if ($writeOnly) { + $this->validateFeatureSupport(SchemaFeature::WriteOnly); + } + $this->writeOnly = $writeOnly; return $this; diff --git a/src/Types/Concerns/HasValidation.php b/src/Types/Concerns/HasValidation.php index f0b9de1..05ee480 100644 --- a/src/Types/Concerns/HasValidation.php +++ b/src/Types/Concerns/HasValidation.php @@ -21,8 +21,7 @@ trait HasValidation */ public function validate(mixed $value): void { - $validator = new Validator(); - $validator->parser()->setOption('defaultDraft', '2020-12'); + $validator = $this->makeValidator(); try { $result = $validator->validate( @@ -53,4 +52,18 @@ public function isValid(mixed $value): bool return false; } } + + /** + * Create a configured validator instance. + */ + private function makeValidator(): Validator + { + $validator = new Validator(); + $parser = $validator->parser(); + + $parser->setOption('defaultDraft', '2020-12'); + $parser->setOption('decodeContent', true); + + return $validator; + } } diff --git a/src/Types/Concerns/ValidatesVersionFeatures.php b/src/Types/Concerns/ValidatesVersionFeatures.php index e5d12af..e9c132f 100644 --- a/src/Types/Concerns/ValidatesVersionFeatures.php +++ b/src/Types/Concerns/ValidatesVersionFeatures.php @@ -5,7 +5,6 @@ namespace Cortex\JsonSchema\Types\Concerns; use Cortex\JsonSchema\Enums\SchemaFeature; -use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Exceptions\SchemaException; /** @mixin \Cortex\JsonSchema\Contracts\JsonSchema */ @@ -60,7 +59,7 @@ protected function getVersionAppropriateKeyword(string $modernKeyword): string { // For features that were renamed, use the appropriate keyword for the version return match ($modernKeyword) { - '$defs' => $this->getVersion() === SchemaVersion::Draft_07 ? 'definitions' : '$defs', + '$defs' => $this->isFeatureSupported(SchemaFeature::Defs) ? '$defs' : 'definitions', default => $modernKeyword, }; } diff --git a/src/Types/StringSchema.php b/src/Types/StringSchema.php index 7e84d26..1954b1f 100644 --- a/src/Types/StringSchema.php +++ b/src/Types/StringSchema.php @@ -6,7 +6,9 @@ use Override; use Cortex\JsonSchema\Enums\SchemaType; +use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; +use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\JsonSchema\Exceptions\SchemaException; final class StringSchema extends AbstractSchema @@ -17,6 +19,12 @@ final class StringSchema extends AbstractSchema protected ?string $pattern = null; + protected ?string $contentEncoding = null; + + protected ?string $contentMediaType = null; + + protected JsonSchema|bool|null $contentSchema = null; + public function __construct(?string $title = null, ?SchemaVersion $schemaVersion = null) { parent::__construct(SchemaType::String, $title, $schemaVersion); @@ -66,6 +74,39 @@ public function pattern(string $pattern): static return $this; } + /** + * Set the content encoding. + */ + public function contentEncoding(string $contentEncoding): static + { + $this->validateFeatureSupport(SchemaFeature::ContentEncoding); + $this->contentEncoding = $contentEncoding; + + return $this; + } + + /** + * Set the content media type. + */ + public function contentMediaType(string $contentMediaType): static + { + $this->validateFeatureSupport(SchemaFeature::ContentMediaType); + $this->contentMediaType = $contentMediaType; + + return $this; + } + + /** + * Set the content schema. + */ + public function contentSchema(JsonSchema|bool $contentSchema): static + { + $this->validateFeatureSupport(SchemaFeature::ContentSchema); + $this->contentSchema = $contentSchema; + + return $this; + } + /** * Convert to array. * @@ -76,7 +117,31 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true { $schema = parent::toArray($includeSchemaRef, $includeTitle); - return $this->addLengthToSchema($schema); + $schema = $this->addLengthToSchema($schema); + + return $this->addContentToSchema($schema); + } + + /** + * Get features used by this schema from all traits. + * + * @return array<\Cortex\JsonSchema\Enums\SchemaFeature> + */ + #[Override] + protected function getUsedFeatures(): array + { + $features = [ + ...parent::getUsedFeatures(), + ...$this->getContentFeatures(), + ]; + + $uniqueFeatures = []; + + foreach ($features as $feature) { + $uniqueFeatures[$feature->value] = $feature; + } + + return array_values($uniqueFeatures); } /** @@ -102,4 +167,54 @@ protected function addLengthToSchema(array $schema): array return $schema; } + + /** + * Add content keywords to schema array. + * + * @param array $schema + * + * @return array + */ + protected function addContentToSchema(array $schema): array + { + if ($this->contentEncoding !== null) { + $schema['contentEncoding'] = $this->contentEncoding; + } + + if ($this->contentMediaType !== null) { + $schema['contentMediaType'] = $this->contentMediaType; + } + + if ($this->contentSchema !== null) { + $schema['contentSchema'] = $this->contentSchema instanceof JsonSchema + ? $this->contentSchema->toArray(includeSchemaRef: false, includeTitle: false) + : $this->contentSchema; + } + + return $schema; + } + + /** + * Get content features used by this schema. + * + * @return array<\Cortex\JsonSchema\Enums\SchemaFeature> + */ + protected function getContentFeatures(): array + { + $features = []; + + if ($this->contentEncoding !== null) { + $features[] = SchemaFeature::ContentEncoding; + } + + if ($this->contentMediaType !== null) { + $features[] = SchemaFeature::ContentMediaType; + } + + if ($this->contentSchema !== null) { + $features[] = SchemaFeature::ContentSchema; + } + + return $features; + } } diff --git a/tests/Unit/Converters/JsonConverterTest.php b/tests/Unit/Converters/JsonConverterTest.php index f1c1a7d..0e6276d 100644 --- a/tests/Unit/Converters/JsonConverterTest.php +++ b/tests/Unit/Converters/JsonConverterTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Cortex\JsonSchema\Tests\Unit\Converters; + use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\NullSchema; use Cortex\JsonSchema\Types\ArraySchema; @@ -233,6 +235,20 @@ expect($jsonSchema->toArray()['deprecated'])->toBe(true); }); +it('detects draft-06 schema version from $schema property', function (): void { + $json = [ + '$schema' => 'http://json-schema.org/draft-06/schema#', + 'type' => 'string', + ]; + + // Even though we pass Draft_07, it should detect Draft_06 from the $schema + $converter = new JsonConverter($json, SchemaVersion::Draft_07); + $jsonSchema = $converter->convert(); + + expect($jsonSchema)->toBeInstanceOf(StringSchema::class); + expect($jsonSchema->getVersion())->toBe(SchemaVersion::Draft_06); +}); + it('can handle nested schemas', function (): void { $json = [ 'type' => 'object', @@ -336,3 +352,30 @@ 'default' => 'red', ]); }); + +it('can handle string content annotations', function (): void { + $json = [ + 'type' => 'string', + 'contentEncoding' => 'base64', + 'contentMediaType' => 'application/json', + 'contentSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + ], + ], + ]; + + $converter = new JsonConverter($json, SchemaVersion::Draft_2019_09); + $jsonSchema = $converter->convert(); + + expect($jsonSchema)->toBeInstanceOf(StringSchema::class); + + $output = $jsonSchema->toArray(); + expect($output['contentEncoding'])->toBe('base64'); + expect($output['contentMediaType'])->toBe('application/json'); + expect($output['contentSchema']['type'])->toBe('object'); + expect($output['contentSchema']['properties']['name']['type'])->toBe('string'); +}); diff --git a/tests/Unit/DefinitionsSchemaTest.php b/tests/Unit/DefinitionsSchemaTest.php index bc3abdf..c8e622a 100644 --- a/tests/Unit/DefinitionsSchemaTest.php +++ b/tests/Unit/DefinitionsSchemaTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Cortex\JsonSchema\Tests\Unit\Targets; +namespace Cortex\JsonSchema\Tests\Unit; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaFormat; diff --git a/tests/Unit/IdSchemaTest.php b/tests/Unit/IdSchemaTest.php new file mode 100644 index 0000000..ddb3699 --- /dev/null +++ b/tests/Unit/IdSchemaTest.php @@ -0,0 +1,28 @@ +id('https://example.com/schemas/name'); + + $schemaArray = $stringSchema->toArray(); + + expect($schemaArray)->toHaveKey('$id', 'https://example.com/schemas/name'); + expect($schemaArray)->toHaveKey('type', 'string'); +}); + +it('can convert $id from JSON', function (): void { + $json = [ + '$id' => 'https://example.com/schemas/name', + 'type' => 'string', + ]; + + $jsonSchema = Schema::fromJson($json); + + expect($jsonSchema->toArray())->toHaveKey('$id', 'https://example.com/schemas/name'); +}); diff --git a/tests/Unit/RefSchemaTest.php b/tests/Unit/RefSchemaTest.php index 594c360..9306703 100644 --- a/tests/Unit/RefSchemaTest.php +++ b/tests/Unit/RefSchemaTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Cortex\JsonSchema\Tests\Unit\Targets; +namespace Cortex\JsonSchema\Tests\Unit; use Cortex\JsonSchema\Schema; diff --git a/tests/Unit/Support/DocParserTest.php b/tests/Unit/Support/DocParserTest.php index 8a593e1..9d654cc 100644 --- a/tests/Unit/Support/DocParserTest.php +++ b/tests/Unit/Support/DocParserTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Cortex\JsonSchema\Tests\Unit; +namespace Cortex\JsonSchema\Tests\Unit\Support; use ReflectionClass; use Cortex\JsonSchema\Support\NodeData; diff --git a/tests/Unit/Types/DependentSchemasTest.php b/tests/Unit/Types/DependentSchemasTest.php index 08002be..0bb3576 100644 --- a/tests/Unit/Types/DependentSchemasTest.php +++ b/tests/Unit/Types/DependentSchemasTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +namespace Cortex\JsonSchema\Tests\Unit\Types; + +use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaFormat; use Cortex\JsonSchema\Types\ObjectSchema; diff --git a/tests/Unit/Types/StringSchemaTest.php b/tests/Unit/Types/StringSchemaTest.php index 4e76bd9..ed4d093 100644 --- a/tests/Unit/Types/StringSchemaTest.php +++ b/tests/Unit/Types/StringSchemaTest.php @@ -4,9 +4,11 @@ namespace Cortex\JsonSchema\Tests\Unit\Types; +use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaFormat; use Cortex\JsonSchema\Types\StringSchema; +use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -228,3 +230,74 @@ expect($stringSchema->toArray())->toHaveKey('examples', ['foo', 'bar']); }); + +it('can create a string schema with content annotations', function (): void { + $stringSchema = Schema::string('payload', SchemaVersion::Draft_2019_09) + ->contentEncoding('base64') + ->contentMediaType('application/json') + ->contentSchema( + Schema::object() + ->properties( + Schema::string('name')->required(), + ), + ); + + $schemaArray = $stringSchema->toArray(); + + expect($schemaArray)->toHaveKey('contentEncoding', 'base64'); + expect($schemaArray)->toHaveKey('contentMediaType', 'application/json'); + expect($schemaArray)->toHaveKey('contentSchema.type', 'object'); + expect($schemaArray)->toHaveKey('contentSchema.properties.name.type', 'string'); + + // Valid base64 encoded string that matches the content schema + expect(fn() => $stringSchema->validate(base64_encode('{"name":"Ada"}'))) + ->not->toThrow(SchemaException::class); + + // Invalid base64 encoded string + expect(fn() => $stringSchema->validate('not-a-base64-encoded-string')) + ->toThrow(SchemaException::class, "The value must be encoded as 'base64'"); + + // Does not match the content schema + expect(fn() => $stringSchema->validate(base64_encode('{"foo":"bar"}'))) + ->toThrow(SchemaException::class, 'The JSON content must match schema'); +}); + +it('can create a string schema with boolean contentSchema', function (): void { + $stringSchema = Schema::string('payload', SchemaVersion::Draft_2019_09) + ->contentSchema(false); + + expect($stringSchema->toArray())->toHaveKey('contentSchema', false); +}); + +it('validates contentSchema feature support', function (): void { + $stringSchema = Schema::string('payload', SchemaVersion::Draft_07); + + expect(fn(): StringSchema => $stringSchema->contentSchema(Schema::object()))->toThrow( + SchemaException::class, + 'Feature "Schema for decoded content" is not supported in Draft 7. Minimum version required: Draft 2019-09.', + ); +}); + +it('detects content features correctly', function (): void { + $stringSchema = Schema::string('payload', SchemaVersion::Draft_2019_09); + $stringSchema->contentEncoding('base64') + ->contentMediaType('application/json') + ->contentSchema(Schema::object()); + + $reflection = new ReflectionClass($stringSchema); + $reflectionMethod = $reflection->getMethod('getContentFeatures'); + + $contentFeatures = $reflectionMethod->invoke($stringSchema); + + expect($contentFeatures)->toContain(SchemaFeature::ContentEncoding); + expect($contentFeatures)->toContain(SchemaFeature::ContentMediaType); + expect($contentFeatures)->toContain(SchemaFeature::ContentSchema); + + // Test that features are included in overall feature detection + $getUsedMethod = $reflection->getMethod('getUsedFeatures'); + + $allFeatures = $getUsedMethod->invoke($stringSchema); + expect($allFeatures)->toContain(SchemaFeature::ContentEncoding); + expect($allFeatures)->toContain(SchemaFeature::ContentMediaType); + expect($allFeatures)->toContain(SchemaFeature::ContentSchema); +}); diff --git a/tests/Unit/Types/UnevaluatedItemsTest.php b/tests/Unit/Types/UnevaluatedItemsTest.php index d390446..566b8f1 100644 --- a/tests/Unit/Types/UnevaluatedItemsTest.php +++ b/tests/Unit/Types/UnevaluatedItemsTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +namespace Cortex\JsonSchema\Tests\Unit\Types; + +use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Enums\SchemaVersion; diff --git a/tests/Unit/Types/UnevaluatedPropertiesTest.php b/tests/Unit/Types/UnevaluatedPropertiesTest.php index 809fec2..503a62d 100644 --- a/tests/Unit/Types/UnevaluatedPropertiesTest.php +++ b/tests/Unit/Types/UnevaluatedPropertiesTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +namespace Cortex\JsonSchema\Tests\Unit\Types; + +use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Enums\SchemaVersion; diff --git a/tests/Unit/VersionFeatureValidationTest.php b/tests/Unit/VersionFeatureValidationTest.php index 544079d..16aa19c 100644 --- a/tests/Unit/VersionFeatureValidationTest.php +++ b/tests/Unit/VersionFeatureValidationTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +namespace Cortex\JsonSchema\Tests\Unit; + +use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Enums\SchemaFormat; @@ -11,13 +14,18 @@ use Cortex\JsonSchema\Exceptions\SchemaException; it('validates conditional features against schema version', function (): void { - // Draft 07 supports if-then-else (this should work) - $stringSchema = Schema::string('test', SchemaVersion::Draft_07); + // Draft 06 does not support if-then-else + $draft06Schema = Schema::string('test', SchemaVersion::Draft_06); $conditionSchema = Schema::string('condition'); + expect(fn(): StringSchema => $draft06Schema->if($conditionSchema))->toThrow(SchemaException::class); + + // Draft 07+ supports if-then-else (this should work) + $stringSchema = Schema::string('test', SchemaVersion::Draft_07); + expect(fn(): StringSchema => $stringSchema->if($conditionSchema))->not->toThrow(SchemaException::class); - // All versions support if-then-else, so this should work for all versions + // Draft 07+ should work $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); @@ -28,6 +36,15 @@ it('outputs version-appropriate definition keywords', function (): void { $stringSchema = Schema::string('definition'); + // Draft 06 should use 'definitions' + $draft06Schema = Schema::object('test', SchemaVersion::Draft_06); + $draft06Schema->addDefinition('myDef', $stringSchema); + + $draft06Array = $draft06Schema->toArray(); + + expect($draft06Array)->toHaveKey('definitions'); + expect($draft06Array)->not->toHaveKey('$defs'); + // Draft 07 should use 'definitions' $objectSchema = Schema::object('test', SchemaVersion::Draft_07); $objectSchema->addDefinition('myDef', $stringSchema); @@ -91,15 +108,22 @@ }); it('correctly identifies version-appropriate keywords', function (): void { + $draft06Schema = Schema::string('test', SchemaVersion::Draft_06); $stringSchema = Schema::string('test', SchemaVersion::Draft_07); $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); + $reflection06 = new ReflectionClass($draft06Schema); + $reflectionMethod06 = $reflection06->getMethod('getVersionAppropriateKeyword'); + $reflection07 = new ReflectionClass($stringSchema); $reflectionMethod = $reflection07->getMethod('getVersionAppropriateKeyword'); $reflection201909 = new ReflectionClass($draft201909Schema); $method201909 = $reflection201909->getMethod('getVersionAppropriateKeyword'); + // Draft 06 should use 'definitions' + expect($reflectionMethod06->invoke($draft06Schema, '$defs', 'definitions'))->toBe('definitions'); + // Draft 07 should use 'definitions' expect($reflectionMethod->invoke($stringSchema, '$defs', 'definitions'))->toBe('definitions'); @@ -183,6 +207,51 @@ expect(fn(): StringSchema => $draft202012Schema->deprecated())->not->toThrow(SchemaException::class); }); +it('validates comment feature against schema version', function (): void { + // Draft 06 should reject $comment + $stringSchema = Schema::string('test', SchemaVersion::Draft_06); + + expect(fn(): StringSchema => $stringSchema->comment('Not allowed'))->toThrow( + SchemaException::class, + 'Feature "Schema annotation comment" is not supported in Draft 6. Minimum version required: Draft 7.', + ); + + // Draft 07+ should accept $comment + $draft07Schema = Schema::string('test', SchemaVersion::Draft_07); + $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); + $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); + + expect(fn(): StringSchema => $draft07Schema->comment('Allowed'))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft201909Schema->comment('Allowed'))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft202012Schema->comment('Allowed'))->not->toThrow(SchemaException::class); +}); + +it('validates read/write features against schema version', function (): void { + // Draft 06 should reject readOnly/writeOnly + $stringSchema = Schema::string('test', SchemaVersion::Draft_06); + + expect(fn(): StringSchema => $stringSchema->readOnly())->toThrow( + SchemaException::class, + 'Feature "Read-only property annotation" is not supported in Draft 6. Minimum version required: Draft 7.', + ); + expect(fn(): StringSchema => $stringSchema->writeOnly())->toThrow( + SchemaException::class, + 'Feature "Write-only property annotation" is not supported in Draft 6. Minimum version required: Draft 7.', + ); + + // Draft 07+ should accept readOnly/writeOnly + $draft07Schema = Schema::string('test', SchemaVersion::Draft_07); + $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); + $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); + + expect(fn(): StringSchema => $draft07Schema->readOnly())->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft07Schema->writeOnly())->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft201909Schema->readOnly())->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft201909Schema->writeOnly())->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft202012Schema->readOnly())->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft202012Schema->writeOnly())->not->toThrow(SchemaException::class); +}); + it('validates array contains count features against schema version', function (): void { // Draft 07 should reject minContains/maxContains $arraySchema = Schema::array('test', SchemaVersion::Draft_07); @@ -210,7 +279,7 @@ $stringSchema = Schema::string('test', SchemaVersion::Draft_2019_09); // Add metadata features - $stringSchema->deprecated()->readOnly(); + $stringSchema->deprecated()->comment('Deprecated field')->readOnly(); $reflection = new ReflectionClass($stringSchema); $reflectionMethod = $reflection->getMethod('getMetadataFeatures'); @@ -221,6 +290,7 @@ $readWriteFeatures = $getReadWriteMethod->invoke($stringSchema); expect($metadataFeatures)->toContain(SchemaFeature::Deprecated); + expect($metadataFeatures)->toContain(SchemaFeature::Comment); expect($readWriteFeatures)->toContain(SchemaFeature::ReadOnly); // Test that features are included in overall feature detection @@ -228,6 +298,7 @@ $allFeatures = $getUsedMethod->invoke($stringSchema); expect($allFeatures)->toContain(SchemaFeature::Deprecated); + expect($allFeatures)->toContain(SchemaFeature::Comment); expect($allFeatures)->toContain(SchemaFeature::ReadOnly); }); @@ -252,6 +323,26 @@ }); it('validates format features against schema version', function (): void { + // Draft 06 should reject formats introduced in Draft 07 + $draft06Schema = Schema::string('test', SchemaVersion::Draft_06); + + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Date))->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Time))->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Regex))->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::RelativeJsonPointer))->toThrow( + SchemaException::class, + ); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::IdnEmail))->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::IdnHostname))->toThrow( + SchemaException::class, + ); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Iri))->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::IriReference))->toThrow(SchemaException::class); + + // Draft 06 should accept formats available in Draft 06 + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Email))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::DateTime))->not->toThrow(SchemaException::class); + // Draft 07 should reject duration and uuid formats $stringSchema = Schema::string('test', SchemaVersion::Draft_07); @@ -320,6 +411,38 @@ expect($emailFormatFeatures)->toBeEmpty(); }); +it('validates content encoding and media type features against schema version', function (): void { + // Draft 06 should reject contentEncoding/contentMediaType + $stringSchema = Schema::string('test', SchemaVersion::Draft_06); + + expect(fn(): StringSchema => $stringSchema->contentEncoding('base64'))->toThrow( + SchemaException::class, + 'Feature "Content encoding annotation" is not supported in Draft 6. Minimum version required: Draft 7.', + ); + expect(fn(): StringSchema => $stringSchema->contentMediaType('application/json'))->toThrow( + SchemaException::class, + 'Feature "Content media type annotation" is not supported in Draft 6. Minimum version required: Draft 7.', + ); + + // Draft 07+ should accept contentEncoding/contentMediaType + $draft07Schema = Schema::string('test', SchemaVersion::Draft_07); + $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); + $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); + + expect(fn(): StringSchema => $draft07Schema->contentEncoding('base64'))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft07Schema->contentMediaType('application/json'))->not->toThrow( + SchemaException::class, + ); + expect(fn(): StringSchema => $draft201909Schema->contentEncoding('base64'))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft201909Schema->contentMediaType('application/json'))->not->toThrow( + SchemaException::class, + ); + expect(fn(): StringSchema => $draft202012Schema->contentEncoding('base64'))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft202012Schema->contentMediaType('application/json'))->not->toThrow( + SchemaException::class, + ); +}); + it('allows string formats for custom validation', function (): void { // String formats should not trigger validation (for custom formats) $stringSchema = Schema::string('test', SchemaVersion::Draft_07); diff --git a/tests/Unit/VersionSupportTest.php b/tests/Unit/VersionSupportTest.php index 1cb05f0..afed95f 100644 --- a/tests/Unit/VersionSupportTest.php +++ b/tests/Unit/VersionSupportTest.php @@ -16,92 +16,132 @@ }); it('has correct schema version enum values', function (): void { + expect(SchemaVersion::Draft_06->value)->toBe('http://json-schema.org/draft-06/schema#'); expect(SchemaVersion::Draft_07->value)->toBe('http://json-schema.org/draft-07/schema#'); expect(SchemaVersion::Draft_2019_09->value)->toBe('https://json-schema.org/draft/2019-09/schema'); expect(SchemaVersion::Draft_2020_12->value)->toBe('https://json-schema.org/draft/2020-12/schema'); }); it('has correct schema version names', function (): void { + expect(SchemaVersion::Draft_06->getName())->toBe('Draft 6'); expect(SchemaVersion::Draft_07->getName())->toBe('Draft 7'); expect(SchemaVersion::Draft_2019_09->getName())->toBe('Draft 2019-09'); expect(SchemaVersion::Draft_2020_12->getName())->toBe('Draft 2020-12'); }); it('has correct schema version years', function (): void { + expect(SchemaVersion::Draft_06->getYear())->toBe(2017); expect(SchemaVersion::Draft_07->getYear())->toBe(2018); expect(SchemaVersion::Draft_2019_09->getYear())->toBe(2019); expect(SchemaVersion::Draft_2020_12->getYear())->toBe(2020); }); it('has correct schema version feature support', function (): void { + $draft06 = SchemaVersion::Draft_06; $draft07 = SchemaVersion::Draft_07; $draft201909 = SchemaVersion::Draft_2019_09; $draft202012 = SchemaVersion::Draft_2020_12; - // Draft 07 features (available in all versions) + // Draft 07 features + expect($draft06->supports(SchemaFeature::IfThenElse))->toBeFalse(); expect($draft07->supports(SchemaFeature::IfThenElse))->toBeTrue(); expect($draft201909->supports(SchemaFeature::IfThenElse))->toBeTrue(); expect($draft202012->supports(SchemaFeature::IfThenElse))->toBeTrue(); + expect($draft06->supports(SchemaFeature::ContentMediaType))->toBeFalse(); expect($draft07->supports(SchemaFeature::ContentMediaType))->toBeTrue(); expect($draft201909->supports(SchemaFeature::ContentMediaType))->toBeTrue(); expect($draft202012->supports(SchemaFeature::ContentMediaType))->toBeTrue(); + expect($draft06->supports(SchemaFeature::Comment))->toBeFalse(); + expect($draft07->supports(SchemaFeature::Comment))->toBeTrue(); + expect($draft201909->supports(SchemaFeature::Comment))->toBeTrue(); + expect($draft202012->supports(SchemaFeature::Comment))->toBeTrue(); + // Draft 2019-09 new features + expect($draft06->supports(SchemaFeature::Anchor))->toBeFalse(); expect($draft07->supports(SchemaFeature::Anchor))->toBeFalse(); expect($draft201909->supports(SchemaFeature::Anchor))->toBeTrue(); expect($draft202012->supports(SchemaFeature::Anchor))->toBeTrue(); + expect($draft06->supports(SchemaFeature::Defs))->toBeFalse(); expect($draft07->supports(SchemaFeature::Defs))->toBeFalse(); expect($draft201909->supports(SchemaFeature::Defs))->toBeTrue(); expect($draft202012->supports(SchemaFeature::Defs))->toBeTrue(); + expect($draft06->supports(SchemaFeature::UnevaluatedProperties))->toBeFalse(); expect($draft07->supports(SchemaFeature::UnevaluatedProperties))->toBeFalse(); expect($draft201909->supports(SchemaFeature::UnevaluatedProperties))->toBeTrue(); expect($draft202012->supports(SchemaFeature::UnevaluatedProperties))->toBeTrue(); + expect($draft06->supports(SchemaFeature::DependentRequired))->toBeFalse(); expect($draft07->supports(SchemaFeature::DependentRequired))->toBeFalse(); expect($draft201909->supports(SchemaFeature::DependentRequired))->toBeTrue(); expect($draft202012->supports(SchemaFeature::DependentRequired))->toBeTrue(); + expect($draft06->supports(SchemaFeature::ContentSchema))->toBeFalse(); expect($draft07->supports(SchemaFeature::ContentSchema))->toBeFalse(); expect($draft201909->supports(SchemaFeature::ContentSchema))->toBeTrue(); expect($draft202012->supports(SchemaFeature::ContentSchema))->toBeTrue(); + expect($draft06->supports(SchemaFeature::Deprecated))->toBeFalse(); expect($draft07->supports(SchemaFeature::Deprecated))->toBeFalse(); expect($draft201909->supports(SchemaFeature::Deprecated))->toBeTrue(); expect($draft202012->supports(SchemaFeature::Deprecated))->toBeTrue(); // 2019-09 only features (replaced in 2020-12) + expect($draft06->supports(SchemaFeature::RecursiveRefLegacy))->toBeFalse(); expect($draft07->supports(SchemaFeature::RecursiveRefLegacy))->toBeFalse(); expect($draft201909->supports(SchemaFeature::RecursiveRefLegacy))->toBeTrue(); expect($draft202012->supports(SchemaFeature::RecursiveRefLegacy))->toBeFalse(); + expect($draft06->supports(SchemaFeature::RecursiveRef))->toBeFalse(); expect($draft07->supports(SchemaFeature::RecursiveRef))->toBeFalse(); expect($draft201909->supports(SchemaFeature::RecursiveRef))->toBeTrue(); expect($draft202012->supports(SchemaFeature::RecursiveRef))->toBeFalse(); // Replaced by $dynamicRef in 2020-12 // Draft 2020-12 features + expect($draft06->supports(SchemaFeature::DynamicRef))->toBeFalse(); expect($draft07->supports(SchemaFeature::DynamicRef))->toBeFalse(); expect($draft201909->supports(SchemaFeature::DynamicRef))->toBeFalse(); expect($draft202012->supports(SchemaFeature::DynamicRef))->toBeTrue(); + expect($draft06->supports(SchemaFeature::PrefixItems))->toBeFalse(); expect($draft07->supports(SchemaFeature::PrefixItems))->toBeFalse(); expect($draft201909->supports(SchemaFeature::PrefixItems))->toBeFalse(); expect($draft202012->supports(SchemaFeature::PrefixItems))->toBeTrue(); // 2020-12 vocabulary and format changes + expect($draft06->supports(SchemaFeature::FormatAnnotation))->toBeFalse(); expect($draft07->supports(SchemaFeature::FormatAnnotation))->toBeFalse(); expect($draft201909->supports(SchemaFeature::FormatAnnotation))->toBeFalse(); expect($draft202012->supports(SchemaFeature::FormatAnnotation))->toBeTrue(); + expect($draft06->supports(SchemaFeature::UnevaluatedVocabulary))->toBeFalse(); expect($draft07->supports(SchemaFeature::UnevaluatedVocabulary))->toBeFalse(); expect($draft201909->supports(SchemaFeature::UnevaluatedVocabulary))->toBeFalse(); expect($draft202012->supports(SchemaFeature::UnevaluatedVocabulary))->toBeTrue(); + expect($draft06->supports(SchemaFeature::UnicodeRegex))->toBeFalse(); expect($draft07->supports(SchemaFeature::UnicodeRegex))->toBeFalse(); expect($draft201909->supports(SchemaFeature::UnicodeRegex))->toBeFalse(); expect($draft202012->supports(SchemaFeature::UnicodeRegex))->toBeTrue(); + + // Draft 07 format additions + expect($draft06->supports(SchemaFeature::FormatDate))->toBeFalse(); + expect($draft07->supports(SchemaFeature::FormatDate))->toBeTrue(); + expect($draft201909->supports(SchemaFeature::FormatDate))->toBeTrue(); + expect($draft202012->supports(SchemaFeature::FormatDate))->toBeTrue(); + + expect($draft06->supports(SchemaFeature::FormatIdnEmail))->toBeFalse(); + expect($draft07->supports(SchemaFeature::FormatIdnEmail))->toBeTrue(); + expect($draft201909->supports(SchemaFeature::FormatIdnEmail))->toBeTrue(); + expect($draft202012->supports(SchemaFeature::FormatIdnEmail))->toBeTrue(); + + expect($draft06->supports(SchemaFeature::FormatIdnHostname))->toBeFalse(); + expect($draft07->supports(SchemaFeature::FormatIdnHostname))->toBeTrue(); + expect($draft201909->supports(SchemaFeature::FormatIdnHostname))->toBeTrue(); + expect($draft202012->supports(SchemaFeature::FormatIdnHostname))->toBeTrue(); }); it('supports enum-based feature checks', function (): void { @@ -161,12 +201,15 @@ }); it('includes correct schema version in output', function (): void { + $draft06Schema = Schema::string('test', SchemaVersion::Draft_06); $stringSchema = Schema::string('test', SchemaVersion::Draft_07); $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); + $draft06Array = $draft06Schema->toArray(); $draft07Array = $stringSchema->toArray(); $draft202012Array = $draft202012Schema->toArray(); + expect($draft06Array['$schema'])->toBe('http://json-schema.org/draft-06/schema#'); expect($draft07Array['$schema'])->toBe('http://json-schema.org/draft-07/schema#'); expect($draft202012Array['$schema'])->toBe('https://json-schema.org/draft/2020-12/schema'); });