From ad03e9d20e8297810cf02ef4f3c655b446244db7 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Sun, 2 Aug 2026 22:56:59 +0100 Subject: [PATCH 1/8] chore: PHP 8.4 and upgrade pest --- .github/actions/setup-php-composer/action.yml | 2 +- .github/workflows/run-tests.yml | 2 +- README.md | 4 +- composer.json | 12 +- docs/json-schema/installation.mdx | 2 +- docs/json-schema/introduction.mdx | 6 +- phpstan.dist.neon | 1 + rector.php | 4 + src/Converters/ClassConverter.php | 2 +- src/Converters/JsonConverter.php | 26 +- src/Enums/SchemaVersion.php | 2 +- src/Exceptions/SchemaException.php | 2 +- src/Schema.php | 2 +- tests/Pest.php | 4 +- tests/Unit/ConditionalSchemaTest.php | 85 +-- tests/Unit/Converters/ClassConverterTest.php | 473 +++++++------- .../Unit/Converters/ClosureConverterTest.php | 590 +++++++++--------- tests/Unit/Converters/EnumConverterTest.php | 38 +- tests/Unit/Converters/JsonConverterTest.php | 280 +++++---- tests/Unit/DefinitionsSchemaTest.php | 22 +- tests/Unit/Enums/SchemaTypeTest.php | 5 +- tests/Unit/IdSchemaTest.php | 4 +- tests/Unit/RefSchemaTest.php | 10 +- tests/Unit/SchemaTest.php | 128 ++-- tests/Unit/Support/DocParserTest.php | 192 +++--- tests/Unit/Types/ArraySchemaTest.php | 114 ++-- tests/Unit/Types/BooleanSchemaTest.php | 35 +- tests/Unit/Types/DependentSchemasTest.php | 140 +++-- tests/Unit/Types/IntegerSchemaTest.php | 53 +- tests/Unit/Types/NullSchemaTest.php | 39 +- tests/Unit/Types/NumberSchemaTest.php | 86 +-- tests/Unit/Types/ObjectSchemaTest.php | 115 ++-- tests/Unit/Types/StringSchemaTest.php | 132 ++-- tests/Unit/Types/TypelessSchemaTest.php | 16 +- tests/Unit/Types/UnevaluatedItemsTest.php | 51 +- .../Unit/Types/UnevaluatedPropertiesTest.php | 52 +- tests/Unit/Types/UnionSchemaTest.php | 57 +- tests/Unit/VersionFeatureValidationTest.php | 210 ++++--- tests/Unit/VersionSupportTest.php | 300 +++++---- 39 files changed, 1743 insertions(+), 1555 deletions(-) diff --git a/.github/actions/setup-php-composer/action.yml b/.github/actions/setup-php-composer/action.yml index 39cf403..e91d9b1 100644 --- a/.github/actions/setup-php-composer/action.yml +++ b/.github/actions/setup-php-composer/action.yml @@ -5,7 +5,7 @@ inputs: php-version: description: 'PHP version to use' required: false - default: '8.3' + default: '8.4' coverage: description: 'Coverage driver to use (none, xdebug, pcov)' required: false diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3ce3c69..de822d2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -17,7 +17,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.3, 8.4, 8.5] + php: [8.4, 8.5] stability: [prefer-lowest, prefer-stable] name: PHP${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/README.md b/README.md index 2d33ec6..32da189 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - ✅ **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 — including docblock array generics and constructor property promotion -- 💪 **Type Safety** - Built with PHP 8.3+ features and strict typing +- 💪 **Type Safety** - Built with PHP 8.4+ features and strict typing - 🔍 **Version-Aware Features** - Automatic validation of version-specific features with helpful error messages ## JSON Schema Version Support @@ -29,7 +29,7 @@ This package supports multiple JSON Schema specification versions with automatic ## Requirements -- PHP 8.3+ +- PHP 8.4+ ## Installation diff --git a/composer.json b/composer.json index 7af0fee..1bdfdfa 100644 --- a/composer.json +++ b/composer.json @@ -19,18 +19,20 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "ext-json": "*", "opis/json-schema": "^2.4", "phpstan/phpdoc-parser": "^2.3" }, "require-dev": { - "pestphp/pest": "^4.1.4", - "pestphp/pest-plugin-type-coverage": "^4.0.3", + "pestphp/pest": "^5.0", + "pestphp/pest-plugin-phpstan": "^5.0", + "pestphp/pest-plugin-rector": "^5.0", + "pestphp/pest-plugin-type-coverage": "^5.0", "phpstan/phpstan": "^2.1.32", "phpstan/phpstan-strict-rules": "^2.0", "rector/rector": "^2.2", - "symplify/easy-coding-standard": "^13.0" + "symplify/easy-coding-standard": "~13.1.0" }, "autoload": { "psr-4": { @@ -43,7 +45,7 @@ } }, "scripts": { - "test": "pest", + "test": "pest --parallel --tia", "ecs": "ecs check --fix", "ecs:check": "ecs check", "rector": "rector process", diff --git a/docs/json-schema/installation.mdx b/docs/json-schema/installation.mdx index ded5a73..a76388c 100644 --- a/docs/json-schema/installation.mdx +++ b/docs/json-schema/installation.mdx @@ -6,7 +6,7 @@ icon: 'terminal' ## Requirements -- PHP 8.3+ +- PHP 8.4+ - Composer for dependency management ## Installation Steps diff --git a/docs/json-schema/introduction.mdx b/docs/json-schema/introduction.mdx index 0002e2e..81f8d24 100644 --- a/docs/json-schema/introduction.mdx +++ b/docs/json-schema/introduction.mdx @@ -36,7 +36,7 @@ icon: 'book-open' href="/json-schema/installation" icon="shield" > - Built with PHP 8.3+ features and strict typing for reliable, maintainable code. + Built with PHP 8.4+ features and strict typing for reliable, maintainable code. isValid($data)) { ``` -This package requires PHP 8.3 or higher and uses modern PHP features for optimal performance and developer experience. +This package requires PHP 8.4 or higher and uses modern PHP features for optimal performance and developer experience. ## Supported Schema Types diff --git a/phpstan.dist.neon b/phpstan.dist.neon index e4fcd01..9ccafbd 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,6 @@ includes: - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/pestphp/pest-plugin-phpstan/extension.neon parameters: level: 10 diff --git a/rector.php b/rector.php index efd5116..ca471f4 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Pest\Rector\Set\PestSetList; return RectorConfig::configure() ->withPaths([ @@ -15,6 +16,9 @@ removeUnusedImports: true, ) ->withPhpSets() + ->withSets([ + PestSetList::CODING_STYLE, + ]) ->withPreparedSets( deadCode: true, codeQuality: true, diff --git a/src/Converters/ClassConverter.php b/src/Converters/ClassConverter.php index 92cb4f5..5a00a31 100644 --- a/src/Converters/ClassConverter.php +++ b/src/Converters/ClassConverter.php @@ -239,7 +239,7 @@ protected function getConstructorParams(): ?NodeCollection $docComment = $constructor?->getDocComment(); return is_string($docComment) - ? (new DocParser($docComment))->params() + ? new DocParser($docComment)->params() : null; } diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php index 7246bd6..84e13ed 100644 --- a/src/Converters/JsonConverter.php +++ b/src/Converters/JsonConverter.php @@ -161,7 +161,7 @@ private function getBoolOrSchema(string $key): bool|JsonSchema|null } if (is_array($value)) { - return (new self($value, $this->schemaVersion))->convert(); + return new self($value, $this->schemaVersion)->convert(); } return null; @@ -348,7 +348,7 @@ private function applyDefinitions(AbstractSchema $schema): void continue; } - $schema->addDefinition($name, (new self($definitionData, $this->schemaVersion))->convert()); + $schema->addDefinition($name, new self($definitionData, $this->schemaVersion)->convert()); } } @@ -372,7 +372,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $ob continue; } - $propertySchemas[$name] = (new self($propertyData, $this->schemaVersion))->convert(); + $propertySchemas[$name] = new self($propertyData, $this->schemaVersion)->convert(); if (in_array($name, $required, true)) { $requiredProps[] = $name; @@ -401,12 +401,12 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $ob continue; } - $objectSchema->patternProperty($pattern, (new self($propertyData, $this->schemaVersion))->convert()); + $objectSchema->patternProperty($pattern, new self($propertyData, $this->schemaVersion)->convert()); } } if (($propertyNames = $this->getArray('propertyNames')) !== null) { - $objectSchema->propertyNames((new self($propertyNames, $this->schemaVersion))->convert()); + $objectSchema->propertyNames(new self($propertyNames, $this->schemaVersion)->convert()); } if (($additionalProperties = $this->getBoolOrSchema('additionalProperties')) !== null) { @@ -427,7 +427,7 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $ob continue; } - $objectSchema->dependentSchema($property, (new self($dependentData, $this->schemaVersion))->convert()); + $objectSchema->dependentSchema($property, new self($dependentData, $this->schemaVersion)->convert()); } } @@ -471,7 +471,7 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void if (is_array($items)) { if (array_is_list($items)) { $tupleSchemas = array_values(array_map( - fn(array $item): JsonSchema => (new self($item, $this->schemaVersion))->convert(), + fn(array $item): JsonSchema => new self($item, $this->schemaVersion)->convert(), array_filter($items, is_array(...)), )); @@ -479,7 +479,7 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void $arraySchema->tupleItems($tupleSchemas); } } else { - $arraySchema->items((new self($items, $this->schemaVersion))->convert()); + $arraySchema->items(new self($items, $this->schemaVersion)->convert()); } } @@ -489,7 +489,7 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void 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(), + fn(array $item): JsonSchema => new self($item, $this->schemaVersion)->convert(), array_filter($prefixItems, is_array(...)), )); @@ -511,7 +511,7 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void } if (($contains = $this->getArray('contains')) !== null) { - $arraySchema->contains((new self($contains, $this->schemaVersion))->convert()); + $arraySchema->contains(new self($contains, $this->schemaVersion)->convert()); } if (($minContains = $this->getInt('minContains')) !== null) { @@ -604,7 +604,7 @@ private function convertSubschema(mixed $value): ?JsonSchema return null; } - return (new self($value, $this->schemaVersion))->convert(); + return new self($value, $this->schemaVersion)->convert(); } /** @@ -621,7 +621,7 @@ private function getArrayOfSchemas(string $key): array } return array_values(array_map( - fn(array $item): JsonSchema => (new self($item, $this->schemaVersion))->convert(), + fn(array $item): JsonSchema => new self($item, $this->schemaVersion)->convert(), array_filter($value, is_array(...)), )); } @@ -668,7 +668,7 @@ private function createStringSchema(?string $title): StringSchema $contentSchema = $this->getValue('contentSchema'); if (is_array($contentSchema)) { - $stringSchema->contentSchema((new self($contentSchema, $this->schemaVersion))->convert()); + $stringSchema->contentSchema(new self($contentSchema, $this->schemaVersion)->convert()); } elseif (is_bool($contentSchema)) { $stringSchema->contentSchema($contentSchema); } diff --git a/src/Enums/SchemaVersion.php b/src/Enums/SchemaVersion.php index 2953dde..6a0bdeb 100644 --- a/src/Enums/SchemaVersion.php +++ b/src/Enums/SchemaVersion.php @@ -56,7 +56,7 @@ public function supports(SchemaFeature $schemaFeature): bool } // Check if this version is not beyond the maximum supported version - return ! ($maxVersion instanceof self && $this->getYear() > $maxVersion->getYear()); + return ! $maxVersion instanceof self || $this->getYear() <= $maxVersion->getYear(); } /** diff --git a/src/Exceptions/SchemaException.php b/src/Exceptions/SchemaException.php index 56a7513..044d142 100644 --- a/src/Exceptions/SchemaException.php +++ b/src/Exceptions/SchemaException.php @@ -15,7 +15,7 @@ class SchemaException extends Exception public static function failedValidation(ValidationError $validationError): self { $exception = new self( - (new ErrorFormatter())->formatErrorMessage($validationError), + new ErrorFormatter()->formatErrorMessage($validationError), ); $exception->setError($validationError); diff --git a/src/Schema.php b/src/Schema.php index 2e4bccd..5b0aa5f 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -198,7 +198,7 @@ public static function from( */ public static function fromJson(string|array $json, ?SchemaVersion $schemaVersion = null): JsonSchema { - return (new JsonConverter($json, $schemaVersion ?? self::getDefaultVersion()))->convert(); + return new JsonConverter($json, $schemaVersion ?? self::getDefaultVersion())->convert(); } /** diff --git a/tests/Pest.php b/tests/Pest.php index dc05243..e28a741 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,4 +4,6 @@ namespace Cortex\JsonSchema\Tests; -uses(TestCase::class)->in('Unit'); +pest() + ->extend(TestCase::class) + ->in('Unit'); diff --git a/tests/Unit/ConditionalSchemaTest.php b/tests/Unit/ConditionalSchemaTest.php index 58e0bd6..d127491 100644 --- a/tests/Unit/ConditionalSchemaTest.php +++ b/tests/Unit/ConditionalSchemaTest.php @@ -37,10 +37,9 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('if'); - expect($schemaArray)->toHaveKey('then'); - expect($schemaArray)->toHaveKey('else'); - expect($schemaArray['if'])->toHaveKey('properties.type.const', 'business'); + expect($schemaArray)->toHaveKeys(['if', 'then', 'else']) + ->and($schemaArray['if']) + ->toHaveKey('properties.type.const', 'business'); // Validation tests // Business type requires company_name and tax_id @@ -115,8 +114,9 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('allOf'); - expect($schemaArray['allOf'])->toHaveCount(3); + expect($schemaArray)->toHaveKey('allOf') + ->and($schemaArray['allOf']) + ->toHaveCount(3); // Test US address validation expect(fn() => $objectSchema->validate([ @@ -156,13 +156,13 @@ 'street_address' => '123 Main St', 'country' => 'Canada', 'postal_code' => '12345', - ]))->toThrow(SchemaException::class); - - expect(fn() => $objectSchema->validate([ - 'street_address' => '123 Main St', - 'country' => 'Netherlands', - 'postal_code' => 'AB12 CD', - ]))->toThrow(SchemaException::class); + ]))->toThrow(SchemaException::class) + ->and(fn() => $objectSchema->validate([ + 'street_address' => '123 Main St', + 'country' => 'Netherlands', + 'postal_code' => 'AB12 CD', + ])) + ->toThrow(SchemaException::class); }); it('can create a schema with anyOf condition', function (): void { @@ -182,10 +182,13 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('anyOf'); - expect($schemaArray['anyOf'])->toHaveCount(2); - expect($schemaArray['anyOf'][0])->toHaveKey('required', ['credit_card']); - expect($schemaArray['anyOf'][1])->toHaveKey('required', ['bank_transfer']); + expect($schemaArray)->toHaveKey('anyOf') + ->and($schemaArray['anyOf']) + ->toHaveCount(2) + ->sequence( + fn($e) => $e->toHaveKey('required', ['credit_card']), + fn($e) => $e->toHaveKey('required', ['bank_transfer']), + ); // Validation tests expect(fn() => $objectSchema->validate([ @@ -194,21 +197,17 @@ expect(fn() => $objectSchema->validate([ 'bank_transfer' => 'TRANSFER123', - ]))->not->toThrow(SchemaException::class); - - expect(fn() => $objectSchema->validate([ - 'bank_transfer' => 'TRANSFER123', - 'credit_card' => '4111111111111111', - ]))->not->toThrow(SchemaException::class); - - expect(fn() => $objectSchema->validate([ - 'credit_card' => 'invalid', - ]))->toThrow(SchemaException::class, 'The data should match at least one schema'); - - expect(fn() => $objectSchema->validate(new ArrayObject()))->toThrow( - SchemaException::class, - 'The data should match at least one schema', - ); + ]))->not->toThrow(SchemaException::class) + ->and(fn() => $objectSchema->validate([ + 'bank_transfer' => 'TRANSFER123', + 'credit_card' => '4111111111111111', + ]))->not->toThrow(SchemaException::class) + ->and(fn() => $objectSchema->validate([ + 'credit_card' => 'invalid', + ])) + ->toThrow(SchemaException::class, 'The data should match at least one schema') + ->and(fn() => $objectSchema->validate(new ArrayObject())) + ->toThrow(SchemaException::class, 'The data should match at least one schema'); }); it('can create a schema with oneOf condition', function (): void { @@ -226,10 +225,10 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('oneOf'); - expect($schemaArray['oneOf'])->toHaveCount(2); - expect($schemaArray['oneOf'][0])->toHaveKey('required', ['email']); - expect($schemaArray['oneOf'][1])->toHaveKey('required', ['phone']); + expect($schemaArray)->toHaveKey('oneOf') + ->and($schemaArray['oneOf']) + ->toHaveCount(2) + ->sequence(fn($e) => $e->toHaveKey('required', ['email']), fn($e) => $e->toHaveKey('required', ['phone'])); // Validation tests // Only email is valid @@ -259,15 +258,17 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('not'); - expect($schemaArray['not'])->toHaveKey('enum', ['deleted', 'banned']); + expect($schemaArray)->toHaveKey('not') + ->and($schemaArray['not']) + ->toHaveKey('enum', ['deleted', 'banned']); // Validation tests expect(fn() => $stringSchema->validate('active'))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('inactive'))->not->toThrow(SchemaException::class); - - expect(fn() => $stringSchema->validate('deleted'))->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('banned'))->toThrow(SchemaException::class); + expect(fn() => $stringSchema->validate('inactive'))->not->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate('deleted')) + ->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate('banned')) + ->toThrow(SchemaException::class); }); it('throws exception when setting then without if', function (): void { diff --git a/tests/Unit/Converters/ClassConverterTest.php b/tests/Unit/Converters/ClassConverterTest.php index 35b251c..762bb8e 100644 --- a/tests/Unit/Converters/ClassConverterTest.php +++ b/tests/Unit/Converters/ClassConverterTest.php @@ -12,42 +12,43 @@ covers(ClassConverter::class); it('can create a schema from a class', function (): void { - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { public string $name; public ?int $age = null; public float $height = 1.7; - }))->convert(); + })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', - ], - 'age' => [ - 'type' => [ - 'integer', - 'null', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => [ + 'integer', + 'null', + ], + 'default' => null, + ], + 'height' => [ + 'type' => 'number', + 'default' => 1.7, ], - 'default' => null, ], - 'height' => [ - 'type' => 'number', - 'default' => 1.7, + 'required' => [ + 'name', ], - ], - 'required' => [ - 'name', - ], - ]); + ]); }); it('can create a schema from a class with docblocks', function (): void { - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { /** * @var string The name of the user */ @@ -62,35 +63,36 @@ * @var float The height of the user in meters */ public float $height = 1.7; - }))->convert(); + })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => 'The name of the user', - ], - 'age' => [ - 'type' => [ - 'integer', - 'null', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user', + ], + 'age' => [ + 'type' => [ + 'integer', + 'null', + ], + 'description' => 'The age of the user', + 'default' => null, + ], + 'height' => [ + 'type' => 'number', + 'description' => 'The height of the user in meters', + 'default' => 1.7, ], - 'description' => 'The age of the user', - 'default' => null, ], - 'height' => [ - 'type' => 'number', - 'description' => 'The height of the user in meters', - 'default' => 1.7, + 'required' => [ + 'name', ], - ], - 'required' => [ - 'name', - ], - ]); + ]); }); it('can create a schema from a class with constructor property promotion', function (): void { @@ -105,26 +107,27 @@ public function __construct( ) {} }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'This is the description of the class', - 'properties' => [ - 'name' => [ - 'type' => 'string', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'This is the description of the class', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => 'integer', + 'default' => 20, + ], ], - 'age' => [ - 'type' => 'integer', - 'default' => 20, + 'required' => [ + 'name', ], - ], - 'required' => [ - 'name', - ], - ]); + ]); }); it('can create a schema from a class with an enum', function (): void { @@ -135,30 +138,31 @@ enum UserStatus: string case Pending = 'pending'; } - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { public string $name; public UserStatus $status = UserStatus::Pending; - }))->convert(); + })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'status' => [ + 'type' => 'string', + 'enum' => ['active', 'inactive', 'pending'], + 'default' => 'pending', + ], ], - 'status' => [ - 'type' => 'string', - 'enum' => ['active', 'inactive', 'pending'], - 'default' => 'pending', + 'required' => [ + 'name', ], - ], - 'required' => [ - 'name', - ], - ]); + ]); }); it('can create a schema from a deprecated class', function (): void { @@ -184,37 +188,38 @@ enum UserStatus: string public ?string $email = null; }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'A deprecated user model class', - 'deprecated' => true, - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => "The user's name", - ], - 'age' => [ - 'type' => 'integer', - 'description' => "The user's age", - ], - 'email' => [ - 'type' => [ - 'string', - 'null', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'A deprecated user model class', + 'deprecated' => true, + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => "The user's name", + ], + 'age' => [ + 'type' => 'integer', + 'description' => "The user's age", + ], + 'email' => [ + 'type' => [ + 'string', + 'null', + ], + 'description' => "The user's email address", + 'default' => null, ], - 'description' => "The user's email address", - 'default' => null, ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + 'required' => [ + 'name', + 'age', + ], + ]); }); it('can create a schema from a class with deprecated properties', function (): void { @@ -244,42 +249,43 @@ enum UserStatus: string public string $birthDate; }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => "The user's name", - ], - 'age' => [ - 'type' => 'integer', - 'description' => "The user's age", - 'deprecated' => true, - ], - 'email' => [ - 'type' => [ - 'string', - 'null', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => "The user's name", + ], + 'age' => [ + 'type' => 'integer', + 'description' => "The user's age", + 'deprecated' => true, + ], + 'email' => [ + 'type' => [ + 'string', + 'null', + ], + 'description' => "The user's email address", + 'default' => null, + 'deprecated' => true, + ], + 'birthDate' => [ + 'type' => 'string', + 'description' => "The user's birth date", ], - 'description' => "The user's email address", - 'default' => null, - 'deprecated' => true, ], - 'birthDate' => [ - 'type' => 'string', - 'description' => "The user's birth date", + 'required' => [ + 'name', + 'age', + 'birthDate', ], - ], - 'required' => [ - 'name', - 'age', - 'birthDate', - ], - ]); + ]); }); it('can create a schema from a deprecated class with deprecated properties', function (): void { @@ -302,30 +308,31 @@ enum UserStatus: string public int $age; }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'Legacy user model', - 'deprecated' => true, - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => "The user's name", + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'Legacy user model', + 'deprecated' => true, + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => "The user's name", + ], + 'age' => [ + 'type' => 'integer', + 'description' => "The user's age", + 'deprecated' => true, + ], ], - 'age' => [ - 'type' => 'integer', - 'description' => "The user's age", - 'deprecated' => true, + 'required' => [ + 'name', + 'age', ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + ]); }); it('can create a schema from a deprecated class without description', function (): void { @@ -338,26 +345,27 @@ enum UserStatus: string public int $age; }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'deprecated' => true, - 'properties' => [ - 'name' => [ - 'type' => 'string', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'deprecated' => true, + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => 'integer', + ], ], - 'age' => [ - 'type' => 'integer', + 'required' => [ + 'name', + 'age', ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + ]); }); it('can create a schema from a class with inheritance', function (): void { @@ -387,18 +395,18 @@ class CompleteUserInheritanceTest extends BaseEntityInheritanceTest public string $email; } - $objectSchema = (new ClassConverter(CompleteUserInheritanceTest::class))->convert(); + $objectSchema = new ClassConverter(CompleteUserInheritanceTest::class)->convert(); expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); // Should include properties from both base class and child class expect($array['properties'])->toHaveKey('id'); - expect($array['properties'])->toHaveKey('created_at'); - expect($array['properties'])->toHaveKey('name'); - expect($array['properties'])->toHaveKey('email'); - expect($array['properties']['id']['description'])->toBe('Unique identifier'); - expect($array['properties']['created_at']['description'])->toBe('Creation timestamp'); + expect($array['properties'])->toHaveKeys(['created_at', 'name', 'email']) + ->and($array['properties']['id']['description']) + ->toBe('Unique identifier') + ->and($array['properties']['created_at']['description']) + ->toBe('Creation timestamp'); }); it('can create a schema from a class with traits', function (): void { @@ -425,17 +433,19 @@ class UserWithTraitTest public string $email; } - $objectSchema = (new ClassConverter(UserWithTraitTest::class))->convert(); + $objectSchema = new ClassConverter(UserWithTraitTest::class)->convert(); expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); // Should include properties from trait and class expect($array['properties'])->toHaveKey('deleted_at'); - expect($array['properties'])->toHaveKey('name'); - expect($array['properties'])->toHaveKey('email'); - expect($array['properties']['deleted_at']['description'])->toBe('Deletion timestamp (soft delete)'); - expect($array['properties']['deleted_at']['default'])->toBe(null); + expect($array['properties'])->toHaveKeys(['name', 'email']) + ->and($array['properties']['deleted_at']) + ->toMatchArray([ + 'description' => 'Deletion timestamp (soft delete)', + 'default' => null, + ]); }); it('can create a schema from a class with inheritance and traits', function (): void { @@ -469,35 +479,30 @@ class CompleteUserCombinedTest extends BaseEntityCombinedTest public string $email; } - $objectSchema = (new ClassConverter(CompleteUserCombinedTest::class))->convert(); + $objectSchema = new ClassConverter(CompleteUserCombinedTest::class)->convert(); expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); // Should include properties from base class, trait, and child class expect($array['properties'])->toHaveKey('id'); - expect($array['properties'])->toHaveKey('created_at'); - expect($array['properties'])->toHaveKey('deleted_at'); - expect($array['properties'])->toHaveKey('name'); - expect($array['properties'])->toHaveKey('email'); + expect($array['properties'])->toHaveKeys(['created_at', 'deleted_at', 'name', 'email']); }); it('ignores static properties', function (): void { - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { public string $name = 'John'; public static string $table = 'users'; public static int $count = 0; - }))->convert(); + })->convert(); expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); - expect($array['properties'])->toHaveKey('name'); - expect($array['properties'])->not->toHaveKey('table'); - expect($array['properties'])->not->toHaveKey('count'); + expect($array['properties'])->toHaveKey('name')->not->toHaveKey('table')->not->toHaveKey('count'); }); it('uses constructor @param tags to describe promoted properties', function (): void { @@ -515,28 +520,29 @@ public function __construct( ) {} }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'A user data transfer object', - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => 'The name of the user', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'A user data transfer object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user', + ], + 'age' => [ + 'type' => 'integer', + 'description' => 'The age of the user in years', + 'default' => 20, + ], ], - 'age' => [ - 'type' => 'integer', - 'description' => 'The age of the user in years', - 'default' => 20, + 'required' => [ + 'name', ], - ], - 'required' => [ - 'name', - ], - ]); + ]); }); it('prefers a property @var description over the constructor @param description', function (): void { @@ -552,7 +558,7 @@ public function __construct( ) {} }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); $array = $objectSchema->toArray(); @@ -564,7 +570,7 @@ public function __construct( public DateTime $createdAt; }; - expect(fn(): ObjectSchema => (new ClassConverter($class))->convert()) + expect(fn(): ObjectSchema => new ClassConverter($class)->convert()) ->toThrow(UnknownTypeException::class); }); @@ -575,23 +581,22 @@ public function __construct( public DateTime $createdAt; }; - $objectSchema = (new ClassConverter($class, ignoreUnknownTypes: true))->convert(); + $objectSchema = new ClassConverter($class, ignoreUnknownTypes: true)->convert(); expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); - expect($array['properties'])->toHaveKey('name'); - expect($array['properties'])->not->toHaveKey('createdAt'); + expect($array['properties'])->toHaveKey('name')->not->toHaveKey('createdAt'); }); it('can create array items schema from @var string[] docblock', function (): void { - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { /** * @var string[] The user's tags */ public array $tags; - }))->convert(); + })->convert(); expect($objectSchema->toArray())->toBe([ 'type' => 'object', @@ -622,7 +627,7 @@ public function __construct( ) {} }; - $objectSchema = (new ClassConverter($class))->convert(); + $objectSchema = new ClassConverter($class)->convert(); expect($objectSchema->toArray()['properties']['ids'])->toBe([ 'type' => 'array', @@ -635,12 +640,12 @@ public function __construct( }); it('can create array items schema for nullable array properties', function (): void { - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { /** * @var string[] Optional tags */ public ?array $tags = null; - }))->convert(); + })->convert(); expect($objectSchema->toArray()['properties']['tags'])->toBe([ 'type' => [ @@ -657,12 +662,12 @@ public function __construct( }); it('leaves array properties without items when element type is unmappable', function (): void { - $objectSchema = (new ClassConverter(new class () { + $objectSchema = new ClassConverter(new class () { /** * @var DateTime[] Scheduled dates */ public array $dates; - }))->convert(); + })->convert(); expect($objectSchema->toArray()['properties']['dates'])->toBe([ 'type' => 'array', diff --git a/tests/Unit/Converters/ClosureConverterTest.php b/tests/Unit/Converters/ClosureConverterTest.php index e128a2a..7657682 100644 --- a/tests/Unit/Converters/ClosureConverterTest.php +++ b/tests/Unit/Converters/ClosureConverterTest.php @@ -14,32 +14,33 @@ it('can create a schema from a closure', function (): void { $closure = function (string $name, array $fooArray, ?int $age = null): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', - ], - 'fooArray' => [ - 'type' => 'array', - ], - 'age' => [ - 'type' => [ - 'integer', - 'null', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'fooArray' => [ + 'type' => 'array', + ], + 'age' => [ + 'type' => [ + 'integer', + 'null', + ], + 'default' => null, ], - 'default' => null, ], - ], - 'required' => [ - 'name', - 'fooArray', - ], - ]); + 'required' => [ + 'name', + 'fooArray', + ], + ]); }); it('can create a schema from a closure with a string backed enum', function (): void { @@ -50,29 +51,30 @@ enum MyEnum: string } $closure = function (MyEnum $myEnum, bool $foo = true): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'myEnum' => [ - 'type' => 'string', - 'enum' => [ - 'a', - 'b', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'myEnum' => [ + 'type' => 'string', + 'enum' => [ + 'a', + 'b', + ], + ], + 'foo' => [ + 'type' => 'boolean', + 'default' => true, ], ], - 'foo' => [ - 'type' => 'boolean', - 'default' => true, + 'required' => [ + 'myEnum', ], - ], - 'required' => [ - 'myEnum', - ], - ]); + ]); }); it('can create a schema from a closure with an integer backed enum', function (): void { @@ -90,28 +92,29 @@ enum Status: int */ $closure = function (Status $status): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'Do something with the status', - 'properties' => [ - 'status' => [ - 'type' => 'integer', - 'description' => 'The status of the post', - 'enum' => [ - 0, - 1, - 2, + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'Do something with the status', + 'properties' => [ + 'status' => [ + 'type' => 'integer', + 'description' => 'The status of the post', + 'enum' => [ + 0, + 1, + 2, + ], ], ], - ], - 'required' => [ - 'status', - ], - ]); + 'required' => [ + 'status', + ], + ]); }); it('throws an exception if the enum is not a backed enum', function (): void { @@ -123,7 +126,7 @@ enum StatusNoBackingType } $closure = function (StatusNoBackingType $statusNoBackingType): void {}; - (new ClosureConverter($closure))->convert(); + new ClosureConverter($closure)->convert(); })->throws( SchemaException::class, 'Enum type has no backing type: Cortex\JsonSchema\Tests\Unit\Converters\StatusNoBackingType', @@ -138,13 +141,14 @@ public function __construct( } $closure = function (UnknownType $unknownType): void {}; - $objectSchema = (new ClosureConverter($closure, ignoreUnknownTypes: true))->convert(); + $objectSchema = new ClosureConverter($closure, ignoreUnknownTypes: true)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - ]); + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + ]); }); it('can ignore unknown types while preserving known types', function (): void { @@ -156,25 +160,26 @@ public function __construct( } $closure = function (string $name, CustomClass $customClass, int $age): void {}; - $objectSchema = (new ClosureConverter($closure, ignoreUnknownTypes: true))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', + $objectSchema = new ClosureConverter($closure, ignoreUnknownTypes: true)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => 'integer', + ], ], - 'age' => [ - 'type' => 'integer', + 'required' => [ + 'name', + 'age', ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + ]); }); it('throws an exception for unknown types by default', function (): void { @@ -186,138 +191,144 @@ public function __construct( } $closure = function (AnotherCustomClass $anotherCustomClass): void {}; - (new ClosureConverter($closure))->convert(); + new ClosureConverter($closure)->convert(); })->throws(UnknownTypeException::class, 'Unknown type:'); it('can create a schema from a closure with a union type', function (): void { $closure = function (int|string $foo): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'foo' => [ - 'type' => [ - 'string', - 'integer', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'foo' => [ + 'type' => [ + 'string', + 'integer', + ], ], ], - ], - 'required' => [ - 'foo', - ], - ]); + 'required' => [ + 'foo', + ], + ]); }); it('can create a schema from a closure with a nullable union type', function (): void { $closure = function (int|string|null $foo): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'foo' => [ - 'type' => [ - 'string', - 'integer', - 'null', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'foo' => [ + 'type' => [ + 'string', + 'integer', + 'null', + ], ], ], - ], - 'required' => [ - 'foo', - ], - ]); + 'required' => [ + 'foo', + ], + ]); }); it('can create a schema from a closure with array type hints', function (): void { $closure = function (array $items, array $tags = ['default']): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'items' => [ - 'type' => 'array', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'items' => [ + 'type' => 'array', + ], + 'tags' => [ + 'type' => 'array', + 'default' => ['default'], + ], ], - 'tags' => [ - 'type' => 'array', - 'default' => ['default'], + 'required' => [ + 'items', ], - ], - 'required' => [ - 'items', - ], - ]); + ]); }); it('can create a schema from a closure with mixed type', function (): void { $closure = function (mixed $data): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'data' => [ - 'type' => [ - 'string', - 'number', - 'integer', - 'boolean', - 'object', - 'array', - 'null', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'data' => [ + 'type' => [ + 'string', + 'number', + 'integer', + 'boolean', + 'object', + 'array', + 'null', + ], ], ], - ], - 'required' => [ - 'data', - ], - ]); + 'required' => [ + 'data', + ], + ]); }); it('can create a schema from a closure with object type', function (): void { $closure = function (object $data): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'data' => [ - 'type' => 'object', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'data' => [ + 'type' => 'object', + ], ], - ], - 'required' => [ - 'data', - ], - ]); + 'required' => [ + 'data', + ], + ]); }); it('can create a schema from a closure with float type', function (): void { $closure = function (float $amount = 0.0): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'amount' => [ - 'type' => 'number', - 'default' => 0.0, + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'amount' => [ + 'type' => 'number', + 'default' => 0.0, + ], ], - ], - ]); + ]); }); it('can create a schema from a closure with default values', function (): void { @@ -329,57 +340,59 @@ public function __construct( bool $active = true, ): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); + $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', - ], - 'age' => [ - 'type' => 'integer', - ], - 'email' => [ - 'type' => [ - 'string', - 'null', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => 'integer', + ], + 'email' => [ + 'type' => [ + 'string', + 'null', + ], + 'default' => null, + ], + 'tags' => [ + 'type' => 'array', + 'default' => [], + ], + 'active' => [ + 'type' => 'boolean', + 'default' => true, ], - 'default' => null, - ], - 'tags' => [ - 'type' => 'array', - 'default' => [], ], - 'active' => [ - 'type' => 'boolean', - 'default' => true, + 'required' => [ + 'name', + 'age', ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + ]); }); it('can create a schema from a closure with array type', function (): void { $closure = function (array $items = ['default']): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'items' => [ - 'type' => 'array', - 'default' => ['default'], + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'default' => ['default'], + ], ], - ], - ]); + ]); }); it('can create a schema from a deprecated closure', function (): void { @@ -392,29 +405,30 @@ public function __construct( * @param int $age The user's age */ $closure = function (string $name, int $age): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'A deprecated function for processing user data', - 'deprecated' => true, - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => "The user's name", + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'A deprecated function for processing user data', + 'deprecated' => true, + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => "The user's name", + ], + 'age' => [ + 'type' => 'integer', + 'description' => "The user's age", + ], ], - 'age' => [ - 'type' => 'integer', - 'description' => "The user's age", + 'required' => [ + 'name', + 'age', ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + ]); }); it('can create a schema from a deprecated closure using the deprecated attribute', function (): void { @@ -425,29 +439,30 @@ public function __construct( * @param int $age The user's age */ $closure = #[Deprecated('Use processUserDataV2() instead since v2.0')] function (string $name, int $age): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'A deprecated function for processing user data', - 'deprecated' => true, - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => "The user's name", + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'A deprecated function for processing user data', + 'deprecated' => true, + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => "The user's name", + ], + 'age' => [ + 'type' => 'integer', + 'description' => "The user's age", + ], ], - 'age' => [ - 'type' => 'integer', - 'description' => "The user's age", + 'required' => [ + 'name', + 'age', ], - ], - 'required' => [ - 'name', - 'age', - ], - ]); + ]); })->skipOnPhp('<8.4'); it('can create a schema from a deprecated closure without description', function (): void { @@ -457,23 +472,24 @@ public function __construct( * @param string $data Some data */ $closure = function (string $data): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); - - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'deprecated' => true, - 'properties' => [ - 'data' => [ - 'type' => 'string', - 'description' => 'Some data', + $objectSchema = new ClosureConverter($closure)->convert(); + + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'deprecated' => true, + 'properties' => [ + 'data' => [ + 'type' => 'string', + 'description' => 'Some data', + ], ], - ], - 'required' => [ - 'data', - ], - ]); + 'required' => [ + 'data', + ], + ]); }); it('can create array items schema from @param string[] docblock', function (): void { @@ -481,7 +497,7 @@ public function __construct( * @param string[] $tags The user's tags */ $closure = function (array $tags): void {}; - $objectSchema = (new ClosureConverter($closure))->convert(); + $objectSchema = new ClosureConverter($closure)->convert(); expect($objectSchema->toArray()['properties']['tags'])->toBe([ 'type' => 'array', diff --git a/tests/Unit/Converters/EnumConverterTest.php b/tests/Unit/Converters/EnumConverterTest.php index 27c29bd..5e30ee6 100644 --- a/tests/Unit/Converters/EnumConverterTest.php +++ b/tests/Unit/Converters/EnumConverterTest.php @@ -22,16 +22,17 @@ enum PostStatus: string case Archived = 'archived'; } - $schema = (new EnumConverter(PostStatus::class))->convert(); + $schema = new EnumConverter(PostStatus::class)->convert(); - expect($schema)->toBeInstanceOf(StringSchema::class); - expect($schema->toArray())->toBe([ - 'type' => 'string', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'title' => 'PostStatus', - 'description' => 'This is the description of the string backed enum', - 'enum' => ['draft', 'published', 'archived'], - ]); + expect($schema)->toBeInstanceOf(StringSchema::class) + ->and($schema->toArray()) + ->toBe([ + 'type' => 'string', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'title' => 'PostStatus', + 'description' => 'This is the description of the string backed enum', + 'enum' => ['draft', 'published', 'archived'], + ]); }); it('can create a schema from an integer backed enum', function (): void { @@ -45,16 +46,17 @@ enum PostType: int case Tutorial = 3; } - $schema = (new EnumConverter(PostType::class))->convert(); + $schema = new EnumConverter(PostType::class)->convert(); - expect($schema)->toBeInstanceOf(IntegerSchema::class); - expect($schema->toArray())->toBe([ - 'type' => 'integer', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'title' => 'PostType', - 'description' => 'This is the description of the integer backed enum', - 'enum' => [1, 2, 3], - ]); + expect($schema)->toBeInstanceOf(IntegerSchema::class) + ->and($schema->toArray()) + ->toBe([ + 'type' => 'integer', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'title' => 'PostType', + 'description' => 'This is the description of the integer backed enum', + 'enum' => [1, 2, 3], + ]); }); it('throws an exception if the enum is not a backed enum', function (): void { diff --git a/tests/Unit/Converters/JsonConverterTest.php b/tests/Unit/Converters/JsonConverterTest.php index 97d997c..4558d1b 100644 --- a/tests/Unit/Converters/JsonConverterTest.php +++ b/tests/Unit/Converters/JsonConverterTest.php @@ -33,16 +33,17 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(StringSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => 'string', - 'title' => 'Test String', - 'minLength' => 1, - 'maxLength' => 100, - 'pattern' => '^[a-z]+$', - 'format' => 'email', - 'description' => 'A test string', - ]); + expect($jsonSchema)->toBeInstanceOf(StringSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => 'string', + 'title' => 'Test String', + 'minLength' => 1, + 'maxLength' => 100, + 'pattern' => '^[a-z]+$', + 'format' => 'email', + 'description' => 'A test string', + ]); }); it('can convert object JSON schema with properties', function (): void { @@ -69,12 +70,18 @@ expect($jsonSchema)->toBeInstanceOf(ObjectSchema::class); $output = $jsonSchema->toArray(); - expect($output['type'])->toBe('object'); - expect($output['title'])->toBe('User'); - expect($output['additionalProperties'])->toBe(false); - expect($output['properties']['name']['type'])->toBe('string'); - expect($output['properties']['age']['type'])->toBe('integer'); - expect($output['required'])->toBe(['name']); + expect($output) + ->toMatchArray([ + 'type' => 'object', + 'title' => 'User', + 'additionalProperties' => false, + ]) + ->and($output['properties']['name']['type']) + ->toBe('string') + ->and($output['properties']['age']['type']) + ->toBe('integer') + ->and($output['required']) + ->toBe(['name']); }); it('can convert array JSON schema with items', function (): void { @@ -95,12 +102,19 @@ expect($jsonSchema)->toBeInstanceOf(ArraySchema::class); $output = $jsonSchema->toArray(); - expect($output['type'])->toBe('array'); - expect($output['title'])->toBe('String Array'); - expect($output['items']['type'])->toBe('string'); - expect($output['minItems'])->toBe(1); - expect($output['maxItems'])->toBe(10); - expect($output['uniqueItems'])->toBe(true); + expect($output) + ->toMatchArray([ + 'type' => 'array', + 'title' => 'String Array', + ]) + ->and($output['items']['type']) + ->toBe('string') + ->and($output) + ->toMatchArray([ + 'minItems' => 1, + 'maxItems' => 10, + 'uniqueItems' => true, + ]); }); it('can convert number schema with constraints', function (): void { @@ -114,13 +128,14 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(NumberSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => 'number', - 'minimum' => 0.5, - 'maximum' => 100.5, - 'multipleOf' => 0.5, - ]); + expect($jsonSchema)->toBeInstanceOf(NumberSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => 'number', + 'minimum' => 0.5, + 'maximum' => 100.5, + 'multipleOf' => 0.5, + ]); }); it('can convert integer schema with constraints', function (): void { @@ -135,14 +150,15 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(IntegerSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => 100, - 'exclusiveMinimum' => 0, - 'exclusiveMaximum' => 101, - ]); + expect($jsonSchema)->toBeInstanceOf(IntegerSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + 'exclusiveMinimum' => 0, + 'exclusiveMaximum' => 101, + ]); }); it('can convert boolean schema', function (): void { @@ -155,12 +171,13 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(BooleanSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => 'boolean', - 'default' => true, - 'description' => 'A boolean value', - ]); + expect($jsonSchema)->toBeInstanceOf(BooleanSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => 'boolean', + 'default' => true, + 'description' => 'A boolean value', + ]); }); it('can convert null schema', function (): void { @@ -172,11 +189,12 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(NullSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => 'null', - 'description' => 'A null value', - ]); + expect($jsonSchema)->toBeInstanceOf(NullSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => 'null', + 'description' => 'A null value', + ]); }); it('can convert union schema with multiple types', function (): void { @@ -188,11 +206,12 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(UnionSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => ['string', 'number'], - 'description' => 'String or number', - ]); + expect($jsonSchema)->toBeInstanceOf(UnionSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => ['string', 'number'], + 'description' => 'String or number', + ]); }); it('can handle schema without type as mixed', function (): void { @@ -214,8 +233,9 @@ $converter = new JsonConverter($jsonString, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(StringSchema::class); - expect($jsonSchema->toArray()['minLength'])->toBe(5); + expect($jsonSchema)->toBeInstanceOf(StringSchema::class) + ->and($jsonSchema->toArray()['minLength']) + ->toBe(5); }); it('detects schema version from $schema property', function (): void { @@ -229,11 +249,13 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(StringSchema::class); - expect($jsonSchema->getVersion())->toBe(SchemaVersion::Draft_2019_09); + expect($jsonSchema)->toBeInstanceOf(StringSchema::class) + ->and($jsonSchema->getVersion()) + ->toBe(SchemaVersion::Draft_2019_09); // Should work with deprecated (2019-09+ feature) - expect($jsonSchema->toArray()['deprecated'])->toBe(true); + expect($jsonSchema->toArray()['deprecated']) + ->toBeTrue(); }); it('detects draft-06 schema version from $schema property', function (): void { @@ -246,8 +268,9 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(StringSchema::class); - expect($jsonSchema->getVersion())->toBe(SchemaVersion::Draft_06); + expect($jsonSchema)->toBeInstanceOf(StringSchema::class) + ->and($jsonSchema->getVersion()) + ->toBe(SchemaVersion::Draft_06); }); it('can handle nested schemas', function (): void { @@ -275,9 +298,11 @@ expect($jsonSchema)->toBeInstanceOf(ObjectSchema::class); $output = $jsonSchema->toArray(); - expect($output['properties']['address']['type'])->toBe('object'); - expect($output['properties']['address']['properties']['street']['type'])->toBe('string'); - expect($output['properties']['address']['required'])->toBe(['street']); + expect($output['properties']['address']['type'])->toBe('object') + ->and($output['properties']['address']['properties']['street']['type']) + ->toBe('string') + ->and($output['properties']['address']['required']) + ->toBe(['street']); }); it('throws exception for invalid JSON string', function (): void { @@ -295,7 +320,7 @@ 'type' => 'unsupported', ]; - expect(fn(): JsonSchema => (new JsonConverter($json, SchemaVersion::Draft_07))->convert()) + expect(fn(): JsonSchema => new JsonConverter($json, SchemaVersion::Draft_07)->convert()) ->toThrow(SchemaException::class, 'Unsupported schema type: unsupported'); }); @@ -307,8 +332,9 @@ $jsonSchema = Schema::fromJson($json); - expect($jsonSchema)->toBeInstanceOf(StringSchema::class); - expect($jsonSchema->toArray()['minLength'])->toBe(3); + expect($jsonSchema)->toBeInstanceOf(StringSchema::class) + ->and($jsonSchema->toArray()['minLength']) + ->toBe(3); }); it('can handle array with contains and min/max contains', function (): void { @@ -328,10 +354,14 @@ expect($jsonSchema)->toBeInstanceOf(ArraySchema::class); $output = $jsonSchema->toArray(); - expect($output['type'])->toBe('array'); - expect($output['contains']['type'])->toBe('string'); - expect($output['minContains'])->toBe(1); - expect($output['maxContains'])->toBe(3); + expect($output['type'])->toBe('array') + ->and($output['contains']['type']) + ->toBe('string') + ->and($output) + ->toMatchArray([ + 'minContains' => 1, + 'maxContains' => 3, + ]); }); it('can handle string with enum and const', function (): void { @@ -345,13 +375,14 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_07); $jsonSchema = $converter->convert(); - expect($jsonSchema)->toBeInstanceOf(StringSchema::class); - expect($jsonSchema->toArray())->toMatchArray([ - 'type' => 'string', - 'enum' => ['red', 'green', 'blue'], - 'const' => 'red', - 'default' => 'red', - ]); + expect($jsonSchema)->toBeInstanceOf(StringSchema::class) + ->and($jsonSchema->toArray()) + ->toMatchArray([ + 'type' => 'string', + 'enum' => ['red', 'green', 'blue'], + 'const' => 'red', + 'default' => 'red', + ]); }); it('can handle string content annotations', function (): void { @@ -375,10 +406,15 @@ 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'); + expect($output) + ->toMatchArray([ + 'contentEncoding' => 'base64', + 'contentMediaType' => 'application/json', + ]) + ->and($output['contentSchema']['type']) + ->toBe('object') + ->and($output['contentSchema']['properties']['name']['type']) + ->toBe('string'); }); it('can handle conditional keywords', function (): void { @@ -433,13 +469,15 @@ 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'); + expect($output)->toHaveKeys(['if', 'then', 'else']) + ->and($output['allOf']) + ->toHaveCount(1) + ->and($output['anyOf']) + ->toHaveCount(1) + ->and($output['oneOf']) + ->toHaveCount(1) + ->and($output['not']['type']) + ->toBe('null'); }); it('can handle $ref keyword', function (): void { @@ -513,10 +551,14 @@ $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); + expect($output['patternProperties']['^S_']['type'])->toBe('string') + ->and($output['propertyNames']['pattern']) + ->toBe('^[A-Za-z_]*$') + ->and($output) + ->toMatchArray([ + 'minProperties' => 1, + 'maxProperties' => 5, + ]); }); it('can handle dependentSchemas and dependentRequired', function (): void { @@ -538,8 +580,9 @@ $output = $jsonSchema->toArray(includeSchemaRef: false); expect($output['dependentRequired'])->toBe([ 'foo' => ['bar'], - ]); - expect($output['dependentSchemas']['foo']['required'])->toBe(['bar']); + ]) + ->and($output['dependentSchemas']['foo']['required']) + ->toBe(['bar']); }); it('can handle unevaluatedProperties', function (): void { @@ -551,7 +594,8 @@ $converter = new JsonConverter($json, SchemaVersion::Draft_2020_12); $jsonSchema = $converter->convert(); - expect($jsonSchema->toArray(includeSchemaRef: false)['unevaluatedProperties'])->toBe(false); + expect($jsonSchema->toArray(includeSchemaRef: false)['unevaluatedProperties']) + ->toBeFalse(); }); it('can handle prefixItems and unevaluatedItems', function (): void { @@ -572,10 +616,13 @@ $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); + expect($output['prefixItems'])->toHaveCount(2) + ->and($output['prefixItems'][0]['type']) + ->toBe('string') + ->and($output['prefixItems'][1]['type']) + ->toBe('integer') + ->and($output['unevaluatedItems']) + ->toBeFalse(); }); it('can handle tuple items and additionalItems', function (): void { @@ -596,10 +643,13 @@ $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); + expect($output['items'])->toHaveCount(2) + ->and($output['items'][0]['type']) + ->toBe('string') + ->and($output['items'][1]['type']) + ->toBe('integer') + ->and($output['additionalItems']) + ->toBeFalse(); }); it('can handle $anchor keyword', function (): void { @@ -634,8 +684,9 @@ expect($jsonSchema)->toBeInstanceOf(TypelessSchema::class); $output = $jsonSchema->toArray(includeSchemaRef: false); - expect($output)->not->toHaveKey('type'); - expect($output['$defs']['product']['type'])->toBe('object'); + expect($output)->not->toHaveKey('type') + ->and($output['$defs']['product']['type']) + ->toBe('object'); }); it('can handle boolean const values', function (): void { @@ -663,13 +714,14 @@ $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', - ]); + expect($jsonSchema)->toBeInstanceOf(NumberSchema::class) + ->and($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/DefinitionsSchemaTest.php b/tests/Unit/DefinitionsSchemaTest.php index c8e622a..e4717d2 100644 --- a/tests/Unit/DefinitionsSchemaTest.php +++ b/tests/Unit/DefinitionsSchemaTest.php @@ -22,9 +22,10 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('$defs.address'); - expect($schemaArray['$defs']['address'])->toHaveKey('type', 'object'); - expect($schemaArray['$defs']['address'])->toHaveKey('required', ['street', 'city', 'country']); + expect($schemaArray)->toHaveKey('$defs.address') + ->and($schemaArray['$defs']['address']) + ->toHaveKey('type', 'object') + ->toHaveKey('required', ['street', 'city', 'country']); }); it('can add multiple definitions to a schema', function (): void { @@ -46,10 +47,11 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('$defs.address'); - expect($schemaArray)->toHaveKey('$defs.contact'); - expect($schemaArray['$defs']['address'])->toHaveKey('required', ['street', 'city']); - expect($schemaArray['$defs']['contact'])->toHaveKey('required', ['email']); + expect($schemaArray)->toHaveKeys(['$defs.address', '$defs.contact']) + ->and($schemaArray['$defs']['address']) + ->toHaveKey('required', ['street', 'city']) + ->and($schemaArray['$defs']['contact']) + ->toHaveKey('required', ['email']); }); it('can reference a definition in a schema property', function (): void { @@ -72,9 +74,9 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('$defs.address'); - expect($schemaArray)->toHaveKey('properties.billing_address.$ref', '#/$defs/address'); - expect($schemaArray)->toHaveKey('properties.shipping_address.$ref', '#/$defs/address'); + expect($schemaArray)->toHaveKey('$defs.address') + ->toHaveKey('properties.billing_address.$ref', '#/$defs/address') + ->toHaveKey('properties.shipping_address.$ref', '#/$defs/address'); }); it('validates data against a schema with referenced definitions', function (): void { diff --git a/tests/Unit/Enums/SchemaTypeTest.php b/tests/Unit/Enums/SchemaTypeTest.php index 9f7c561..6d1431b 100644 --- a/tests/Unit/Enums/SchemaTypeTest.php +++ b/tests/Unit/Enums/SchemaTypeTest.php @@ -47,8 +47,9 @@ ]); it('returns null from tryFromScalar for unknown types', function (): void { - expect(SchemaType::tryFromScalar('DateTime'))->toBeNull(); - expect(SchemaType::tryFromScalar('mixed'))->toBeNull(); + expect(SchemaType::tryFromScalar('DateTime'))->toBeNull() + ->and(SchemaType::tryFromScalar('mixed')) + ->toBeNull(); }); it('can create schema instance', function (SchemaType $schemaType, string $expectedClass): void { diff --git a/tests/Unit/IdSchemaTest.php b/tests/Unit/IdSchemaTest.php index ddb3699..9276614 100644 --- a/tests/Unit/IdSchemaTest.php +++ b/tests/Unit/IdSchemaTest.php @@ -12,8 +12,8 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('$id', 'https://example.com/schemas/name'); - expect($schemaArray)->toHaveKey('type', 'string'); + expect($schemaArray)->toHaveKey('$id', 'https://example.com/schemas/name') + ->toHaveKey('type', 'string'); }); it('can convert $id from JSON', function (): void { diff --git a/tests/Unit/RefSchemaTest.php b/tests/Unit/RefSchemaTest.php index 9306703..2ce4ae3 100644 --- a/tests/Unit/RefSchemaTest.php +++ b/tests/Unit/RefSchemaTest.php @@ -12,8 +12,8 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('$ref', '#/definitions/custom'); - expect($schemaArray)->toHaveKey('type', 'string'); + expect($schemaArray)->toHaveKey('$ref', '#/definitions/custom') + ->toHaveKey('type', 'string'); }); it('can create a schema with both $ref and other properties', function (): void { @@ -24,7 +24,7 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('$ref', '#/definitions/custom'); - expect($schemaArray)->toHaveKey('type', ['string', 'null']); - expect($schemaArray)->toHaveKey('description', 'A custom type'); + expect($schemaArray)->toHaveKey('$ref', '#/definitions/custom') + ->toHaveKey('type', ['string', 'null']) + ->toHaveKey('description', 'A custom type'); }); diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php index 757935a..d7b201a 100644 --- a/tests/Unit/SchemaTest.php +++ b/tests/Unit/SchemaTest.php @@ -58,43 +58,44 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('title', 'title'); - expect($schemaArray)->toHaveKey('description', 'Description'); - expect($schemaArray)->toHaveKey('readOnly', true); - expect($schemaArray)->toHaveKey('writeOnly', true); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('title', 'title') + ->toHaveKey('description', 'Description') + ->toHaveKey('readOnly', true) + ->toHaveKey('writeOnly', true); }); it('can create a schema from a closure', function (): void { $closure = function (string $name, array $fooArray, ?int $age = null): void {}; $objectSchema = Schema::fromClosure($closure); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'properties' => [ - 'name' => [ - 'type' => 'string', - ], - 'fooArray' => [ - 'type' => 'array', - ], - 'age' => [ - 'type' => [ - 'integer', - 'null', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'fooArray' => [ + 'type' => 'array', + ], + 'age' => [ + 'type' => [ + 'integer', + 'null', + ], + 'default' => null, ], - 'default' => null, ], - ], - 'required' => [ - 'name', - 'fooArray', - ], - ]); - - expect($objectSchema->toJson())->toBe(json_encode($objectSchema->toArray())); + 'required' => [ + 'name', + 'fooArray', + ], + ]) + ->and($objectSchema->toJson()) + ->toBe(json_encode($objectSchema->toArray())); // Assert that the from method behaves in the same way as the fromClosure method expect(Schema::from($closure))->toEqual($objectSchema); @@ -112,24 +113,25 @@ public function __construct( $objectSchema = Schema::fromClass($class, publicOnly: true); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - expect($objectSchema->toArray())->toBe([ - 'type' => 'object', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'description' => 'This is the description of the class', - 'properties' => [ - 'name' => [ - 'type' => 'string', + expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) + ->and($objectSchema->toArray()) + ->toBe([ + 'type' => 'object', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'description' => 'This is the description of the class', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'age' => [ + 'type' => 'integer', + 'default' => 20, + ], ], - 'age' => [ - 'type' => 'integer', - 'default' => 20, + 'required' => [ + 'name', ], - ], - 'required' => [ - 'name', - ], - ]); + ]); // Assert that the from method behaves in the same way as the fromClass method expect(Schema::from($class))->toEqual($objectSchema); @@ -149,14 +151,15 @@ enum UserRole: string $schema = Schema::fromEnum(UserRole::class); - expect($schema)->toBeInstanceOf(StringSchema::class); - expect($schema->toArray())->toBe([ - 'type' => 'string', - '$schema' => 'https://json-schema.org/draft/2020-12/schema', - 'title' => 'UserRole', - 'description' => 'This is a custom enum for testing', - 'enum' => ['admin', 'editor', 'viewer', 'guest'], - ]); + expect($schema)->toBeInstanceOf(StringSchema::class) + ->and($schema->toArray()) + ->toBe([ + 'type' => 'string', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + 'title' => 'UserRole', + 'description' => 'This is a custom enum for testing', + 'enum' => ['admin', 'editor', 'viewer', 'guest'], + ]); // Assert that the from method behaves in the same way as the fromEnum method expect(Schema::from(UserRole::class))->toEqual($schema); @@ -205,14 +208,12 @@ enum UserRole: string // Test with publicOnly = true (default) $objectSchema = Schema::fromClass($testClass, true); $publicOnlyArray = $objectSchema->toArray(); - expect($publicOnlyArray['properties'])->toHaveKey('publicProp'); - expect($publicOnlyArray['properties'])->not->toHaveKey('protectedProp'); + expect($publicOnlyArray['properties'])->toHaveKey('publicProp')->not->toHaveKey('protectedProp'); // Test with publicOnly = false $allPropsSchema = Schema::fromClass($testClass, false); $allPropsArray = $allPropsSchema->toArray(); - expect($allPropsArray['properties'])->toHaveKey('publicProp'); - expect($allPropsArray['properties'])->toHaveKey('protectedProp'); + expect($allPropsArray['properties'])->toHaveKeys(['publicProp', 'protectedProp']); }); it('tests from method with various input types', function (): void { @@ -296,9 +297,8 @@ enum IntTestEnum: int expect(Schema::from('DateTime'))->toBeInstanceOf(ObjectSchema::class); expect(fn(): JsonSchema => Schema::from('NonExistentClass')) - ->toThrow(SchemaException::class, 'Unsupported value type'); - - expect(fn(): JsonSchema => Schema::from('invalid json')) + ->toThrow(SchemaException::class, 'Unsupported value type') + ->and(fn(): JsonSchema => Schema::from('invalid json')) ->toThrow(SchemaException::class, 'Unsupported value type'); }); @@ -323,15 +323,13 @@ protected function getPrivateProp(): string // Verify that only public properties are included by default $schemaArray = $objectSchema->toArray(); - expect($schemaArray['properties'])->toHaveKey('publicProp'); - expect($schemaArray['properties'])->not->toHaveKey('privateProp'); + expect($schemaArray['properties'])->toHaveKey('publicProp')->not->toHaveKey('privateProp'); // Test that explicitly setting false gives different results $falseSchema = Schema::fromClass($testClass, false); $falseSchemaArray = $falseSchema->toArray(); - expect($falseSchemaArray['properties'])->toHaveKey('publicProp'); - expect($falseSchemaArray['properties'])->toHaveKey('privateProp'); - expect($falseSchema->toArray())->not->toBe($objectSchema->toArray()); + expect($falseSchemaArray['properties'])->toHaveKeys(['publicProp', 'privateProp']) + ->and($falseSchema->toArray())->not->toBe($objectSchema->toArray()); }); it('tests enum type checking edge case', function (): void { diff --git a/tests/Unit/Support/DocParserTest.php b/tests/Unit/Support/DocParserTest.php index 39efa3c..ae411c4 100644 --- a/tests/Unit/Support/DocParserTest.php +++ b/tests/Unit/Support/DocParserTest.php @@ -43,32 +43,47 @@ $params = $parser->params(); - expect($params)->toBeInstanceOf(NodeCollection::class); - - expect($params->get('nickname'))->toBeInstanceOf(NodeData::class); - expect($params->get('nickname')->name)->toBe('nickname'); - expect($params->get('nickname')->description)->toBe('The nickname of the user'); - expect($params->get('nickname')->types)->toBe(['string', 'null']); - - expect($params->get('age'))->toBeInstanceOf(NodeData::class); - expect($params->get('age')->name)->toBe('age'); - expect($params->get('age')->description)->toBe('The age of the user'); - expect($params->get('age')->types)->toBe(['int', 'null']); - - expect($params->get('price'))->toBeInstanceOf(NodeData::class); - expect($params->get('price')->name)->toBe('price'); - expect($params->get('price')->description)->toBe('The price of the product'); - expect($params->get('price')->types)->toBe(['float', 'int']); - - expect($params->get('test'))->toBeInstanceOf(NodeData::class); - expect($params->get('test')->name)->toBe('test'); - expect($params->get('test')->description)->toBe('The test'); - expect($params->get('test')->types)->toBe(['\Cortex\JsonSchema\Tests\Unit\Support\DocParserTest']); - - expect($params->get('any'))->toBeInstanceOf(NodeData::class); - expect($params->get('any')->name)->toBe('any'); - expect($params->get('any')->description)->toBeNull(); - expect($params->get('any')->types)->toBe([]); + expect($params)->toBeInstanceOf(NodeCollection::class) + ->and($params->get('nickname')) + ->toBeInstanceOf(NodeData::class) + ->and($params->get('nickname')->name) + ->toBe('nickname') + ->and($params->get('nickname')->description) + ->toBe('The nickname of the user') + ->and($params->get('nickname')->types) + ->toBe(['string', 'null']) + ->and($params->get('age')) + ->toBeInstanceOf(NodeData::class) + ->and($params->get('age')->name) + ->toBe('age') + ->and($params->get('age')->description) + ->toBe('The age of the user') + ->and($params->get('age')->types) + ->toBe(['int', 'null']) + ->and($params->get('price')) + ->toBeInstanceOf(NodeData::class) + ->and($params->get('price')->name) + ->toBe('price') + ->and($params->get('price')->description) + ->toBe('The price of the product') + ->and($params->get('price')->types) + ->toBe(['float', 'int']) + ->and($params->get('test')) + ->toBeInstanceOf(NodeData::class) + ->and($params->get('test')->name) + ->toBe('test') + ->and($params->get('test')->description) + ->toBe('The test') + ->and($params->get('test')->types) + ->toBe(['\Cortex\JsonSchema\Tests\Unit\Support\DocParserTest']) + ->and($params->get('any')) + ->toBeInstanceOf(NodeData::class) + ->and($params->get('any')->name) + ->toBe('any') + ->and($params->get('any')->description) + ->toBeNull() + ->and($params->get('any')->types) + ->toBeEmpty(); }); it('can parse variables', function (): void { @@ -77,11 +92,13 @@ $variable = $parser->variable(); - expect($variable)->toBeInstanceOf(NodeData::class); - - expect($variable->name)->toBe('nickname'); - expect($variable->types)->toBe(['string']); - expect($variable->description)->toBe('The nickname of the user'); + expect($variable)->toBeInstanceOf(NodeData::class) + ->and($variable->name) + ->toBe('nickname') + ->and($variable->types) + ->toBe(['string']) + ->and($variable->description) + ->toBe('The nickname of the user'); }); it('can parse variables with multiple types', function (): void { @@ -90,11 +107,13 @@ $variable = $parser->variable(); - expect($variable)->toBeInstanceOf(NodeData::class); - - expect($variable->name)->toBe('nickname'); - expect($variable->types)->toBe(['string', 'int']); - expect($variable->description)->toBe('The nickname of the user'); + expect($variable)->toBeInstanceOf(NodeData::class) + ->and($variable->name) + ->toBe('nickname') + ->and($variable->types) + ->toBe(['string', 'int']) + ->and($variable->description) + ->toBe('The nickname of the user'); }); it('can parse a multiline description', function (): void { @@ -115,10 +134,13 @@ $variable = $parser->variable(); - expect($variable)->toBeInstanceOf(NodeData::class); - expect($variable->name)->toBe('name'); - expect($variable->types)->toBe(['string']); - expect($variable->description)->toBeNull(); // Empty description should be null + expect($variable)->toBeInstanceOf(NodeData::class) + ->and($variable->name) + ->toBe('name') + ->and($variable->types) + ->toBe(['string']) + ->and($variable->description) + ->toBeNull(); // Empty description should be null // Compare with non-empty description $docblockWithDescription = '/** @var string $name The user name */'; @@ -136,10 +158,13 @@ $params = $parser->params(); $nameParam = $params->get('name'); - expect($nameParam)->toBeInstanceOf(NodeData::class); - expect($nameParam->name)->toBe('name'); - expect($nameParam->types)->toBe(['string']); - expect($nameParam->description)->toBeNull(); // Empty description should be null + expect($nameParam)->toBeInstanceOf(NodeData::class) + ->and($nameParam->name) + ->toBe('name') + ->and($nameParam->types) + ->toBe(['string']) + ->and($nameParam->description) + ->toBeNull(); // Empty description should be null }); it('correctly maps different type node scenarios', function (): void { @@ -150,9 +175,11 @@ $params = $parser->params(); $typelessParam = $params->get('typeless'); - expect($typelessParam)->toBeInstanceOf(NodeData::class); - expect($typelessParam->name)->toBe('typeless'); - expect($typelessParam->types)->toBe([]); // Should be empty array for typeless + expect($typelessParam)->toBeInstanceOf(NodeData::class) + ->and($typelessParam->name) + ->toBe('typeless') + ->and($typelessParam->types) + ->toBeEmpty(); // Should be empty array for typeless expect($typelessParam->description)->toBeNull(); // Test UnionTypeNode @@ -202,12 +229,8 @@ // Should only return PhpDocTextNode instances, filtering out param/var nodes expect($textNodes)->toBeArray(); - expect($textNodes)->not->toBeEmpty(); - - // All returned nodes should be PhpDocTextNode instances - foreach ($textNodes as $textNode) { - expect($textNode)->toBeInstanceOf(PhpDocTextNode::class); - } + expect($textNodes)->not->toBeEmpty() + ->toContainOnlyInstancesOf(PhpDocTextNode::class); // Test that description parsing works correctly with filtered nodes expect($parser->description())->toBe('This is a description'); @@ -231,21 +254,27 @@ // Test parameter parsing $params = $parser->params(); - expect($params->get('name')->types)->toBe(['string', 'null']); - expect($params->get('name')->description)->toBe('The name parameter'); - - expect($params->get('age')->types)->toBe(['int', 'null']); - expect($params->get('age')->description)->toBe('The age parameter'); - - expect($params->get('typeless')->types)->toBe([]); - expect($params->get('typeless')->description)->toBe('A parameter without type'); + expect($params->get('name')->types)->toBe(['string', 'null']) + ->and($params->get('name')->description) + ->toBe('The name parameter') + ->and($params->get('age')->types) + ->toBe(['int', 'null']) + ->and($params->get('age')->description) + ->toBe('The age parameter') + ->and($params->get('typeless')->types) + ->toBeEmpty() + ->and($params->get('typeless')->description) + ->toBe('A parameter without type'); // Test variable parsing $variable = $parser->variable(); - expect($variable)->toBeInstanceOf(NodeData::class); - expect($variable->name)->toBe('data'); - expect($variable->types)->toBe(['array']); - expect($variable->description)->toBe('Some data'); + expect($variable)->toBeInstanceOf(NodeData::class) + ->and($variable->name) + ->toBe('data') + ->and($variable->types) + ->toBe(['array']) + ->and($variable->description) + ->toBe('Some data'); }); it('handles edge cases in text node filtering', function (): void { @@ -259,9 +288,11 @@ $textOnlyBlock = '/** Just a simple description */'; $textOnlyParser = new DocParser($textOnlyBlock); - expect($textOnlyParser->description())->toBe('Just a simple description'); - expect($textOnlyParser->params()->nodes)->toBeEmpty(); - expect($textOnlyParser->variable())->toBeNull(); + expect($textOnlyParser->description())->toBe('Just a simple description') + ->and($textOnlyParser->params()->nodes) + ->toBeEmpty() + ->and($textOnlyParser->variable()) + ->toBeNull(); }); it('exercises all type mapping branches in mapValueNodeToTypes', function (): void { @@ -274,8 +305,9 @@ $params = $parser->params(); $complexParam = $params->get('complex'); - expect($complexParam->types)->toBe(['string', 'int', 'float', 'bool', 'null']); - expect($complexParam->description)->toBe('Multi-type parameter'); + expect($complexParam->types)->toBe(['string', 'int', 'float', 'bool', 'null']) + ->and($complexParam->description) + ->toBe('Multi-type parameter'); // Test deeply nested nullable type $nestedDocblock = '/** @param ?\Cortex\JsonSchema\Support\NodeData $nested */'; @@ -298,8 +330,9 @@ $varParser = new DocParser($varDocblock); $variable = $varParser->variable(); - expect($variable->types)->toBe(['\DateTime', '\DateTimeImmutable']); - expect($variable->description)->toBe('Date variable'); + expect($variable->types)->toBe(['\DateTime', '\DateTimeImmutable']) + ->and($variable->description) + ->toBe('Date variable'); }); it('directly tests mapValueNodeToTypes method coverage', function (): void { @@ -377,8 +410,9 @@ EOD; $parser = new DocParser($docblock); - expect($parser->isDeprecated())->toBeTrue(); - expect($parser->description())->toBe('This is a test method'); + expect($parser->isDeprecated())->toBeTrue() + ->and($parser->description()) + ->toBe('This is a test method'); }); it('can detect deprecated without description', function (): void { @@ -451,10 +485,12 @@ function (string $docblock, array $expectedItemTypes, string $source): void { EOD; $parser = new DocParser($docblock); - expect($parser->isDeprecated())->toBeTrue(); - expect($parser->description())->toBe('A complex method that does many things'); + expect($parser->isDeprecated())->toBeTrue() + ->and($parser->description()) + ->toBe('A complex method that does many things'); $params = $parser->params(); - expect($params->get('name')->types)->toBe(['string', 'null']); - expect($params->get('age')->types)->toBe(['int']); + expect($params->get('name')->types)->toBe(['string', 'null']) + ->and($params->get('age')->types) + ->toBe(['int']); }); diff --git a/tests/Unit/Types/ArraySchemaTest.php b/tests/Unit/Types/ArraySchemaTest.php index 483e584..66f6e62 100644 --- a/tests/Unit/Types/ArraySchemaTest.php +++ b/tests/Unit/Types/ArraySchemaTest.php @@ -27,16 +27,16 @@ $schemaArray = $arraySchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'array'); - expect($schemaArray)->toHaveKey('title', 'tags'); - expect($schemaArray)->toHaveKey('description', 'List of tags'); - expect($schemaArray)->toHaveKey('minItems', 1); - expect($schemaArray)->toHaveKey('maxItems', 10); - expect($schemaArray)->toHaveKey('uniqueItems', true); - expect($schemaArray)->toHaveKey('items.type', 'string'); - expect($schemaArray)->toHaveKey('items.minLength', 2); - expect($schemaArray)->toHaveKey('items.maxLength', 50); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'array') + ->toHaveKey('title', 'tags') + ->toHaveKey('description', 'List of tags') + ->toHaveKey('minItems', 1) + ->toHaveKey('maxItems', 10) + ->toHaveKey('uniqueItems', true) + ->toHaveKey('items.type', 'string') + ->toHaveKey('items.minLength', 2) + ->toHaveKey('items.maxLength', 50); // Validation tests expect(fn() => $arraySchema->validate([ @@ -109,15 +109,15 @@ $schemaArray = $schema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); - expect($schemaArray)->toHaveKey('type', 'array'); - expect($schemaArray)->toHaveKey('title', 'numbers'); - expect($schemaArray)->toHaveKey('description', 'List of numbers'); - expect($schemaArray)->toHaveKey('contains.type', 'number'); - expect($schemaArray)->toHaveKey('contains.minimum', 10); - expect($schemaArray)->toHaveKey('contains.maximum', 20); - expect($schemaArray)->toHaveKey('minContains', 2); - expect($schemaArray)->toHaveKey('maxContains', 3); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema') + ->toHaveKey('type', 'array') + ->toHaveKey('title', 'numbers') + ->toHaveKey('description', 'List of numbers') + ->toHaveKey('contains.type', 'number') + ->toHaveKey('contains.minimum', 10) + ->toHaveKey('contains.maximum', 20) + ->toHaveKey('minContains', 2) + ->toHaveKey('maxContains', 3); // Valid cases - arrays with 2-3 numbers between 10-20 expect(fn() => $schema->validate([15, 12, 5]))->not->toThrow(SchemaException::class); @@ -163,8 +163,8 @@ ->maxContains(0) ->contains(Schema::number()); - expect($arraySchema->toArray())->toHaveKey('minContains', 0); - expect($arraySchema->toArray())->toHaveKey('maxContains', 0); + expect($arraySchema->toArray())->toHaveKey('minContains', 0) + ->toHaveKey('maxContains', 0); // Test that equal values are acceptable $schema2 = Schema::array('numbers', SchemaVersion::Draft_2019_09) @@ -172,8 +172,8 @@ ->maxContains(3) ->contains(Schema::number()); - expect($schema2->toArray())->toHaveKey('minContains', 3); - expect($schema2->toArray())->toHaveKey('maxContains', 3); + expect($schema2->toArray())->toHaveKey('minContains', 3) + ->toHaveKey('maxContains', 3); }); it('allows equal values for minContains and maxContains', function (): void { @@ -235,9 +235,9 @@ ->unevaluatedItems(Schema::number()); $arrayWithSchema = $schemaWithSchema->toArray(); - expect($arrayWithSchema)->toHaveKey('unevaluatedItems'); - expect($arrayWithSchema['unevaluatedItems'])->toHaveKey('type', 'number'); - expect($arrayWithSchema['unevaluatedItems'])->not->toHaveKey('$schema'); // Should not include schema ref + expect($arrayWithSchema)->toHaveKey('unevaluatedItems') + ->and($arrayWithSchema['unevaluatedItems']) + ->toHaveKey('type', 'number')->not->toHaveKey('$schema'); // Should not include schema ref expect($arrayWithSchema['unevaluatedItems'])->not->toHaveKey('title'); // Should not include title }); @@ -271,8 +271,8 @@ // Should contain all array-specific features expect($featureValues)->toContain('minContains'); - expect($featureValues)->toContain('maxContains'); - expect($featureValues)->toContain('unevaluatedItems'); + expect($featureValues)->toContain('maxContains') + ->toContain('unevaluatedItems'); // Test schema without array features returns empty array $simpleSchema = Schema::array('simple')->items(Schema::string()); @@ -299,8 +299,8 @@ // Should contain array-specific features expect($featureValues)->toContain('minContains'); - expect($featureValues)->toContain('maxContains'); - expect($featureValues)->toContain('unevaluatedItems'); + expect($featureValues)->toContain('maxContains') + ->toContain('unevaluatedItems'); // Should also contain parent features from conditionals expect($featureValues)->toContain('if'); @@ -334,17 +334,13 @@ // Should return an array (not empty as per AlwaysReturnEmptyArray mutation) expect($features)->toBeArray(); - expect($features)->not->toBeEmpty(); - - // Each item should be a SchemaFeature enum - foreach ($features as $feature) { - expect($feature)->toBeInstanceOf(SchemaFeature::class); - } + expect($features) + ->toContainOnlyInstancesOf(SchemaFeature::class)->not->toBeEmpty(); // Should contain the expected features $featureValues = array_map(fn($feature) => $feature->value, $features); - expect($featureValues)->toContain('minContains'); - expect($featureValues)->toContain('unevaluatedItems'); + expect($featureValues)->toContain('minContains') + ->toContain('unevaluatedItems'); }); it('handles toArray parameters correctly for unevaluated items', function (): void { @@ -356,8 +352,8 @@ // UnevaluatedItems schema should not include $schema or title (testing the FalseToTrue mutations) expect($schemaArray['unevaluatedItems'])->not->toHaveKey('$schema'); - expect($schemaArray['unevaluatedItems'])->not->toHaveKey('title'); - expect($schemaArray['unevaluatedItems'])->toHaveKey('type', 'number'); + expect($schemaArray['unevaluatedItems'])->not->toHaveKey('title') + ->toHaveKey('type', 'number'); }); it('can set prefixItems for tuple validation', function (): void { @@ -370,12 +366,15 @@ $schemaArray = $arraySchema->toArray(); - expect($schemaArray)->toHaveKey('prefixItems'); - expect($schemaArray['prefixItems'])->toBeArray(); - expect($schemaArray['prefixItems'])->toHaveCount(3); - expect($schemaArray['prefixItems'][0])->toHaveKey('type', 'string'); - expect($schemaArray['prefixItems'][1])->toHaveKey('type', 'integer'); - expect($schemaArray['prefixItems'][2])->toHaveKey('type', 'boolean'); + expect($schemaArray)->toHaveKey('prefixItems') + ->and($schemaArray['prefixItems']) + ->toBeArray() + ->toHaveCount(3) + ->sequence( + fn($e) => $e->toHaveKey('type', 'string'), + fn($e) => $e->toHaveKey('type', 'integer'), + fn($e) => $e->toHaveKey('type', 'boolean'), + ); // Test basic validation expect($arraySchema->isValid(['hello', 42, true]))->toBeTrue(); @@ -392,10 +391,11 @@ $schemaArray = $arraySchema->toArray(); - expect($schemaArray)->toHaveKey('prefixItems'); - expect($schemaArray)->toHaveKey('items'); - expect($schemaArray['prefixItems'])->toHaveCount(2); - expect($schemaArray['items'])->toHaveKey('type', 'boolean'); + expect($schemaArray)->toHaveKeys(['prefixItems', 'items']) + ->and($schemaArray['prefixItems']) + ->toHaveCount(2) + ->and($schemaArray['items']) + ->toHaveKey('type', 'boolean'); // Valid: prefix items match, additional items match items schema expect($arraySchema->isValid(['name', 42]))->toBeTrue(); @@ -429,8 +429,8 @@ $arraySchema = Schema::array('tuple', SchemaVersion::Draft_2020_12) ->prefixItems([Schema::string()]); - expect($arraySchema->toArray())->toHaveKey('prefixItems'); - expect($arraySchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); + expect($arraySchema->toArray())->toHaveKey('prefixItems') + ->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); }); it('detects prefixItems feature correctly', function (): void { @@ -472,9 +472,9 @@ // PrefixItems schemas should not include $schema or title expect($schemaArray['prefixItems'][0])->not->toHaveKey('$schema'); - expect($schemaArray['prefixItems'][0])->not->toHaveKey('title'); - expect($schemaArray['prefixItems'][0])->toHaveKey('type', 'string'); - expect($schemaArray['prefixItems'][1])->not->toHaveKey('$schema'); - expect($schemaArray['prefixItems'][1])->not->toHaveKey('title'); - expect($schemaArray['prefixItems'][1])->toHaveKey('type', 'integer'); + expect($schemaArray['prefixItems'][0])->not->toHaveKey('title') + ->and($schemaArray['prefixItems']) + ->sequence(fn($e) => $e->toHaveKey('type', 'string'), fn($e) => $e->not->toHaveKey('$schema')) + ->and($schemaArray['prefixItems'][1])->not->toHaveKey('title') + ->toHaveKey('type', 'integer'); }); diff --git a/tests/Unit/Types/BooleanSchemaTest.php b/tests/Unit/Types/BooleanSchemaTest.php index 6c968cc..847a838 100644 --- a/tests/Unit/Types/BooleanSchemaTest.php +++ b/tests/Unit/Types/BooleanSchemaTest.php @@ -16,10 +16,10 @@ $schemaArray = $booleanSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'boolean'); - expect($schemaArray)->toHaveKey('title', 'is_active'); - expect($schemaArray)->toHaveKey('description', 'User active status'); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'boolean') + ->toHaveKey('title', 'is_active') + ->toHaveKey('description', 'User active status'); // Validation tests expect(fn() => $booleanSchema->validate(true))->not->toThrow(SchemaException::class); @@ -34,12 +34,9 @@ expect(fn() => $booleanSchema->validate('true'))->toThrow( SchemaException::class, 'The data (string) must match the type: boolean', - ); - - expect(fn() => $booleanSchema->validate(null))->toThrow( - SchemaException::class, - 'The data (null) must match the type: boolean', - ); + ) + ->and(fn() => $booleanSchema->validate(null)) + ->toThrow(SchemaException::class, 'The data (null) must match the type: boolean'); }); it('can create a boolean schema with read-only property', function (): void { @@ -49,10 +46,10 @@ $schemaArray = $booleanSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'boolean'); - expect($schemaArray)->toHaveKey('title', 'is_verified'); - expect($schemaArray)->toHaveKey('description', 'Email verification status'); - expect($schemaArray)->toHaveKey('readOnly', true); + expect($schemaArray)->toHaveKey('type', 'boolean') + ->toHaveKey('title', 'is_verified') + ->toHaveKey('description', 'Email verification status') + ->toHaveKey('readOnly', true); // Validation tests expect(fn() => $booleanSchema->validate(true))->not->toThrow(SchemaException::class); @@ -72,14 +69,14 @@ $schemaArray = $booleanSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['boolean', 'null']); - expect($schemaArray)->toHaveKey('title', 'is_subscribed'); - expect($schemaArray)->toHaveKey('description', 'Newsletter subscription status'); + expect($schemaArray)->toHaveKey('type', ['boolean', 'null']) + ->toHaveKey('title', 'is_subscribed') + ->toHaveKey('description', 'Newsletter subscription status'); // Validation tests expect(fn() => $booleanSchema->validate(true))->not->toThrow(SchemaException::class); - expect(fn() => $booleanSchema->validate(false))->not->toThrow(SchemaException::class); - expect(fn() => $booleanSchema->validate(null))->not->toThrow(SchemaException::class); + expect(fn() => $booleanSchema->validate(false))->not->toThrow(SchemaException::class) + ->and(fn() => $booleanSchema->validate(null))->not->toThrow(SchemaException::class); // Test invalid types expect(fn() => $booleanSchema->validate(0))->toThrow( diff --git a/tests/Unit/Types/DependentSchemasTest.php b/tests/Unit/Types/DependentSchemasTest.php index 0bb3576..1ba65b4 100644 --- a/tests/Unit/Types/DependentSchemasTest.php +++ b/tests/Unit/Types/DependentSchemasTest.php @@ -26,17 +26,19 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray['dependentSchemas'])->toHaveKey('credit_card'); - expect($schemaArray['dependentSchemas']['credit_card'])->toBe([ - 'type' => 'object', - 'properties' => [ - 'billing_address' => [ - 'type' => 'string', + expect($schemaArray)->toHaveKey('dependentSchemas') + ->and($schemaArray['dependentSchemas']) + ->toHaveKey('credit_card') + ->and($schemaArray['dependentSchemas']['credit_card']) + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'billing_address' => [ + 'type' => 'string', + ], ], - ], - 'required' => ['billing_address'], - ]); + 'required' => ['billing_address'], + ]); // Test basic validation expect($objectSchema->isValid([ @@ -65,30 +67,30 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray['dependentSchemas'])->toHaveKey('credit_card'); - expect($schemaArray['dependentSchemas'])->toHaveKey('phone'); - - expect($schemaArray['dependentSchemas']['credit_card'])->toBe([ - 'type' => 'object', - 'properties' => [ - 'billing_address' => [ - 'type' => 'string', + expect($schemaArray)->toHaveKey('dependentSchemas') + ->and($schemaArray['dependentSchemas']) + ->toHaveKeys(['credit_card', 'phone']) + ->toMatchArray([ + 'credit_card' => [ + 'type' => 'object', + 'properties' => [ + 'billing_address' => [ + 'type' => 'string', + ], + ], + 'required' => ['billing_address'], ], - ], - 'required' => ['billing_address'], - ]); - - expect($schemaArray['dependentSchemas']['phone'])->toBe([ - 'type' => 'object', - 'properties' => [ - 'phone_verified' => [ - 'type' => 'string', - 'enum' => ['yes', 'no'], + 'phone' => [ + 'type' => 'object', + 'properties' => [ + 'phone_verified' => [ + 'type' => 'string', + 'enum' => ['yes', 'no'], + ], + ], + 'required' => ['phone_verified'], ], - ], - 'required' => ['phone_verified'], - ]); + ]); }); it('can add dependent schemas one by one', function (): void { @@ -111,8 +113,9 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray['dependentSchemas'])->toHaveKeys(['credit_card', 'shipping_address']); + expect($schemaArray)->toHaveKey('dependentSchemas') + ->and($schemaArray['dependentSchemas']) + ->toHaveKeys(['credit_card', 'shipping_address']); }); it('throws exception when using dependentSchemas with Draft 07', function (): void { @@ -132,8 +135,8 @@ ) ->dependentSchema('credit_card', Schema::object()); - expect($objectSchema->toArray())->toHaveKey('dependentSchemas'); - expect($objectSchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); + expect($objectSchema->toArray())->toHaveKey('dependentSchemas') + ->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); }); it('works with Draft 2020-12', function (): void { @@ -143,8 +146,8 @@ ) ->dependentSchema('credit_card', Schema::object()); - expect($objectSchema->toArray())->toHaveKey('dependentSchemas'); - expect($objectSchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); + expect($objectSchema->toArray())->toHaveKey('dependentSchemas') + ->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); }); it('detects dependentSchemas feature correctly', function (): void { @@ -199,12 +202,12 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('properties'); - expect($schemaArray)->toHaveKey('required', ['name', 'email']); - expect($schemaArray)->toHaveKey('additionalProperties', false); - expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray)->toHaveKey('minProperties', 2); - expect($schemaArray)->toHaveKey('maxProperties', 10); + expect($schemaArray)->toHaveKey('properties') + ->toHaveKey('required', ['name', 'email']) + ->toHaveKey('additionalProperties', false) + ->toHaveKey('dependentSchemas') + ->toHaveKey('minProperties', 2) + ->toHaveKey('maxProperties', 10); }); it('validates version during schema output', function (): void { @@ -257,9 +260,10 @@ // Verify schema structure expect($schemaArray)->toHaveKey('properties'); - expect($schemaArray)->toHaveKey('required', ['name', 'email']); - expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray['dependentSchemas'])->toHaveKeys(['payment_method', 'is_premium']); + expect($schemaArray)->toHaveKey('required', ['name', 'email']) + ->toHaveKey('dependentSchemas') + ->and($schemaArray['dependentSchemas']) + ->toHaveKeys(['payment_method', 'is_premium']); // Test basic validation (defined properties) expect($objectSchema->isValid([ @@ -296,22 +300,23 @@ // Verify main schema structure expect($schemaArray)->toHaveKey('type', 'object'); - expect($schemaArray)->toHaveKey('title', 'user'); - expect($schemaArray)->toHaveKey('properties'); - expect($schemaArray)->toHaveKey('required', ['name']); + expect($schemaArray)->toHaveKey('title', 'user') + ->toHaveKey('properties') + ->toHaveKey('required', ['name']); // Verify dependent schema structure expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray['dependentSchemas'])->toHaveKey('credit_card'); - expect($schemaArray['dependentSchemas']['credit_card'])->toBe([ - 'type' => 'object', - 'properties' => [ - 'billing_address' => [ - 'type' => 'string', + expect($schemaArray['dependentSchemas'])->toHaveKey('credit_card') + ->and($schemaArray['dependentSchemas']['credit_card']) + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'billing_address' => [ + 'type' => 'string', + ], ], - ], - 'required' => ['billing_address'], - ]); + 'required' => ['billing_address'], + ]); // Test basic validation for data that meets base requirements expect($objectSchema->isValid([ @@ -620,8 +625,9 @@ 'password' => 'secretpassword123', ]); } catch (SchemaException $schemaException) { - expect($schemaException->getErrors())->toHaveCount(1)->toHaveKey('/username'); - expect($schemaException->getErrors()['/username'])->toContain('Minimum string length is 3, found 2'); + expect($schemaException->getErrors())->toHaveCount(1)->toHaveKey('/username') + ->and($schemaException->getErrors()['/username']) + ->toContain('Minimum string length is 3, found 2'); throw $schemaException; } @@ -635,8 +641,9 @@ 'password' => 'short', // Too short ]); } catch (SchemaException $schemaException) { - expect($schemaException->getErrors())->toHaveCount(1)->toHaveKey('/password'); - expect($schemaException->getErrors()['/password'])->toContain('Minimum string length is 8, found 5'); + expect($schemaException->getErrors())->toHaveCount(1)->toHaveKey('/password') + ->and($schemaException->getErrors()['/password']) + ->toContain('Minimum string length is 8, found 5'); throw $schemaException; } @@ -652,10 +659,9 @@ 'billing_email' => 'invalid-email-format', // Invalid email ]); } catch (SchemaException $schemaException) { - expect($schemaException->getErrors())->toHaveCount(1)->toHaveKey('/billing_email'); - expect($schemaException->getErrors()['/billing_email'])->toContain( - "The data must match the 'email' format", - ); + expect($schemaException->getErrors())->toHaveCount(1)->toHaveKey('/billing_email') + ->and($schemaException->getErrors()['/billing_email']) + ->toContain("The data must match the 'email' format"); throw $schemaException; } diff --git a/tests/Unit/Types/IntegerSchemaTest.php b/tests/Unit/Types/IntegerSchemaTest.php index 7794274..a024a9a 100644 --- a/tests/Unit/Types/IntegerSchemaTest.php +++ b/tests/Unit/Types/IntegerSchemaTest.php @@ -18,17 +18,17 @@ $schemaArray = $integerSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'integer'); - expect($schemaArray)->toHaveKey('title', 'age'); - expect($schemaArray)->toHaveKey('description', 'User age'); - expect($schemaArray)->toHaveKey('minimum', 0); - expect($schemaArray)->toHaveKey('maximum', 120); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'integer') + ->toHaveKey('title', 'age') + ->toHaveKey('description', 'User age') + ->toHaveKey('minimum', 0) + ->toHaveKey('maximum', 120); // Validation tests expect(fn() => $integerSchema->validate(25))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(0))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(120))->not->toThrow(SchemaException::class); + expect(fn() => $integerSchema->validate(0))->not->toThrow(SchemaException::class) + ->and(fn() => $integerSchema->validate(120))->not->toThrow(SchemaException::class); // Test out of range values expect(fn() => $integerSchema->validate(-1))->toThrow( @@ -50,12 +50,9 @@ expect(fn() => $integerSchema->validate('25'))->toThrow( SchemaException::class, 'The data (string) must match the type: integer', - ); - - expect(fn() => $integerSchema->validate(null))->toThrow( - SchemaException::class, - 'The data (null) must match the type: integer', - ); + ) + ->and(fn() => $integerSchema->validate(null)) + ->toThrow(SchemaException::class, 'The data (null) must match the type: integer'); }); it('can create an integer schema with exclusive range', function (): void { @@ -66,14 +63,14 @@ $schemaArray = $integerSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'integer'); - expect($schemaArray)->toHaveKey('exclusiveMinimum', 0); - expect($schemaArray)->toHaveKey('exclusiveMaximum', 100); + expect($schemaArray)->toHaveKey('type', 'integer') + ->toHaveKey('exclusiveMinimum', 0) + ->toHaveKey('exclusiveMaximum', 100); // Validation tests expect(fn() => $integerSchema->validate(50))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(1))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(99))->not->toThrow(SchemaException::class); + expect(fn() => $integerSchema->validate(1))->not->toThrow(SchemaException::class) + ->and(fn() => $integerSchema->validate(99))->not->toThrow(SchemaException::class); // Test boundary values expect(fn() => $integerSchema->validate(0))->toThrow( @@ -94,13 +91,13 @@ $schemaArray = $integerSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'integer'); - expect($schemaArray)->toHaveKey('multipleOf', 5); + expect($schemaArray)->toHaveKey('type', 'integer') + ->toHaveKey('multipleOf', 5); // Validation tests expect(fn() => $integerSchema->validate(5))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(10))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(100))->not->toThrow(SchemaException::class); + expect(fn() => $integerSchema->validate(10))->not->toThrow(SchemaException::class) + ->and(fn() => $integerSchema->validate(100))->not->toThrow(SchemaException::class); // Test invalid multiples expect(fn() => $integerSchema->validate(7))->toThrow( @@ -129,9 +126,9 @@ $schemaArray = $integerSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['integer', 'null']); - expect($schemaArray)->toHaveKey('minimum', 1); - expect($schemaArray)->toHaveKey('maximum', 5); + expect($schemaArray)->toHaveKey('type', ['integer', 'null']) + ->toHaveKey('minimum', 1) + ->toHaveKey('maximum', 5); // Validation tests expect(fn() => $integerSchema->validate(3))->not->toThrow(SchemaException::class); @@ -197,8 +194,8 @@ // Any integer should be valid with multipleOf = 1 expect(fn() => $integerSchema->validate(5))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(100))->not->toThrow(SchemaException::class); - expect(fn() => $integerSchema->validate(-5))->not->toThrow(SchemaException::class); + expect(fn() => $integerSchema->validate(100))->not->toThrow(SchemaException::class) + ->and(fn() => $integerSchema->validate(-5))->not->toThrow(SchemaException::class); }); it('validates multipleOf boundary conditions', function (): void { diff --git a/tests/Unit/Types/NullSchemaTest.php b/tests/Unit/Types/NullSchemaTest.php index 4302daf..97059db 100644 --- a/tests/Unit/Types/NullSchemaTest.php +++ b/tests/Unit/Types/NullSchemaTest.php @@ -17,10 +17,10 @@ $schemaArray = $nullSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'null'); - expect($schemaArray)->toHaveKey('title', 'deleted_at'); - expect($schemaArray)->toHaveKey('description', 'Soft delete timestamp'); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'null') + ->toHaveKey('title', 'deleted_at') + ->toHaveKey('description', 'Soft delete timestamp'); // Validation tests expect(fn() => $nullSchema->validate(null))->not->toThrow(SchemaException::class); @@ -34,22 +34,13 @@ expect(fn() => $nullSchema->validate(''))->toThrow( SchemaException::class, 'The data (string) must match the type: null', - ); - - expect(fn() => $nullSchema->validate(false))->toThrow( - SchemaException::class, - 'The data (boolean) must match the type: null', - ); - - expect(fn() => $nullSchema->validate([]))->toThrow( - SchemaException::class, - 'The data (array) must match the type: null', - ); - - expect(fn() => $nullSchema->validate(new stdClass()))->toThrow( - SchemaException::class, - 'The data (object) must match the type: null', - ); + ) + ->and(fn() => $nullSchema->validate(false)) + ->toThrow(SchemaException::class, 'The data (boolean) must match the type: null') + ->and(fn() => $nullSchema->validate([])) + ->toThrow(SchemaException::class, 'The data (array) must match the type: null') + ->and(fn() => $nullSchema->validate(new stdClass())) + ->toThrow(SchemaException::class, 'The data (object) must match the type: null'); }); it('can create a read-only null schema', function (): void { @@ -59,10 +50,10 @@ $schemaArray = $nullSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'null'); - expect($schemaArray)->toHaveKey('title', 'archived_at'); - expect($schemaArray)->toHaveKey('description', 'Archive timestamp'); - expect($schemaArray)->toHaveKey('readOnly', true); + expect($schemaArray)->toHaveKey('type', 'null') + ->toHaveKey('title', 'archived_at') + ->toHaveKey('description', 'Archive timestamp') + ->toHaveKey('readOnly', true); // Validation tests expect(fn() => $nullSchema->validate(null))->not->toThrow(SchemaException::class); diff --git a/tests/Unit/Types/NumberSchemaTest.php b/tests/Unit/Types/NumberSchemaTest.php index 02bbbcd..acdd6a4 100644 --- a/tests/Unit/Types/NumberSchemaTest.php +++ b/tests/Unit/Types/NumberSchemaTest.php @@ -18,17 +18,17 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'number'); - expect($schemaArray)->toHaveKey('title', 'price'); - expect($schemaArray)->toHaveKey('description', 'Product price'); - expect($schemaArray)->toHaveKey('minimum', 0); - expect($schemaArray)->toHaveKey('maximum', 1000); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'number') + ->toHaveKey('title', 'price') + ->toHaveKey('description', 'Product price') + ->toHaveKey('minimum', 0) + ->toHaveKey('maximum', 1000); // Validation tests expect(fn() => $numberSchema->validate(99.99))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(0))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(1000))->not->toThrow(SchemaException::class); + expect(fn() => $numberSchema->validate(0))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(1000))->not->toThrow(SchemaException::class); // Test out of range values expect(fn() => $numberSchema->validate(-1))->toThrow( @@ -61,14 +61,14 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'number'); - expect($schemaArray)->toHaveKey('exclusiveMinimum', 0); - expect($schemaArray)->toHaveKey('exclusiveMaximum', 100); + expect($schemaArray)->toHaveKey('type', 'number') + ->toHaveKey('exclusiveMinimum', 0) + ->toHaveKey('exclusiveMaximum', 100); // Validation tests expect(fn() => $numberSchema->validate(50))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(0.1))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(99.9))->not->toThrow(SchemaException::class); + expect(fn() => $numberSchema->validate(0.1))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(99.9))->not->toThrow(SchemaException::class); // Test boundary values expect(fn() => $numberSchema->validate(0))->toThrow( @@ -89,13 +89,13 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'number'); - expect($schemaArray)->toHaveKey('multipleOf', 0.01); + expect($schemaArray)->toHaveKey('type', 'number') + ->toHaveKey('multipleOf', 0.01); // Validation tests expect(fn() => $numberSchema->validate(10.50))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(0.01))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(100.00))->not->toThrow(SchemaException::class); + expect(fn() => $numberSchema->validate(0.01))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(100.00))->not->toThrow(SchemaException::class); // Test invalid multiples expect(fn() => $numberSchema->validate(10.505))->toThrow( @@ -118,9 +118,9 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['number', 'null']); - expect($schemaArray)->toHaveKey('minimum', 0); - expect($schemaArray)->toHaveKey('maximum', 100); + expect($schemaArray)->toHaveKey('type', ['number', 'null']) + ->toHaveKey('minimum', 0) + ->toHaveKey('maximum', 100); // Validation tests expect(fn() => $numberSchema->validate(50))->not->toThrow(SchemaException::class); @@ -151,10 +151,10 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'number'); - expect($schemaArray)->toHaveKey('title', 'rating'); - expect($schemaArray)->toHaveKey('description', 'Product rating'); - expect($schemaArray)->toHaveKey('enum', [1.0, 2.5, 3.0, 4.5, 5.0]); + expect($schemaArray)->toHaveKey('type', 'number') + ->toHaveKey('title', 'rating') + ->toHaveKey('description', 'Product rating') + ->toHaveKey('enum', [1.0, 2.5, 3.0, 4.5, 5.0]); // Validation tests expect(fn() => $numberSchema->validate(3.5))->toThrow( @@ -162,9 +162,9 @@ 'The data should match one item from enum', ); - expect(fn() => $numberSchema->validate(1.0))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(2.5))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(5.0))->not->toThrow(SchemaException::class); + expect(fn() => $numberSchema->validate(1.0))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(2.5))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(5.0))->not->toThrow(SchemaException::class); }); it('can create a nullable number schema with enum values', function (): void { @@ -175,10 +175,10 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['number', 'null']); - expect($schemaArray)->toHaveKey('title', 'discount'); - expect($schemaArray)->toHaveKey('description', 'Product discount percentage'); - expect($schemaArray)->toHaveKey('enum', [0.1, 0.25, 0.5, null]); + expect($schemaArray)->toHaveKey('type', ['number', 'null']) + ->toHaveKey('title', 'discount') + ->toHaveKey('description', 'Product discount percentage') + ->toHaveKey('enum', [0.1, 0.25, 0.5, null]); // Validation tests expect(fn() => $numberSchema->validate(0.75))->toThrow( @@ -186,10 +186,10 @@ 'The data should match one item from enum', ); - expect(fn() => $numberSchema->validate(0.1))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(0.25))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(0.5))->not->toThrow(SchemaException::class); - expect(fn() => $numberSchema->validate(null))->not->toThrow(SchemaException::class); + expect(fn() => $numberSchema->validate(0.1))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(0.25))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(0.5))->not->toThrow(SchemaException::class) + ->and(fn() => $numberSchema->validate(null))->not->toThrow(SchemaException::class); }); it('can create a number schema with const value', function (): void { @@ -199,10 +199,10 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'number'); - expect($schemaArray)->toHaveKey('title', 'tax_rate'); - expect($schemaArray)->toHaveKey('description', 'Fixed tax rate percentage'); - expect($schemaArray)->toHaveKey('const', 0.21); + expect($schemaArray)->toHaveKey('type', 'number') + ->toHaveKey('title', 'tax_rate') + ->toHaveKey('description', 'Fixed tax rate percentage') + ->toHaveKey('const', 0.21); // Validation tests expect(fn() => $numberSchema->validate(0.20))->toThrow( @@ -221,10 +221,10 @@ $schemaArray = $numberSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['number', 'null']); - expect($schemaArray)->toHaveKey('title', 'standard_fee'); - expect($schemaArray)->toHaveKey('description', 'Standard processing fee'); - expect($schemaArray)->toHaveKey('const', null); + expect($schemaArray)->toHaveKey('type', ['number', 'null']) + ->toHaveKey('title', 'standard_fee') + ->toHaveKey('description', 'Standard processing fee') + ->toHaveKey('const', null); // Validation tests expect(fn() => $numberSchema->validate(0.0))->toThrow( diff --git a/tests/Unit/Types/ObjectSchemaTest.php b/tests/Unit/Types/ObjectSchemaTest.php index d327de4..428116f 100644 --- a/tests/Unit/Types/ObjectSchemaTest.php +++ b/tests/Unit/Types/ObjectSchemaTest.php @@ -36,20 +36,21 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'object'); - expect($schemaArray)->toHaveKey('title', 'user'); - expect($schemaArray)->toHaveKey('description', 'User schema'); - expect($schemaArray)->toHaveKey('properties.name.type', 'string'); - expect($schemaArray)->toHaveKey('properties.name.minLength', 3); - expect($schemaArray)->toHaveKey('properties.name.maxLength', 255); - expect($schemaArray)->toHaveKey('properties.email.type', 'string'); - expect($schemaArray)->toHaveKey('properties.email.format', 'email'); - expect($schemaArray)->toHaveKey('properties.age.type', 'integer'); - expect($schemaArray)->toHaveKey('properties.age.minimum', 18); - expect($schemaArray)->toHaveKey('properties.age.maximum', 150); - expect($schemaArray)->toHaveKey('required', ['name', 'email']); - expect($objectSchema->getPropertyKeys())->toBe(['name', 'email', 'age']); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'object') + ->toHaveKey('title', 'user') + ->toHaveKey('description', 'User schema') + ->toHaveKey('properties.name.type', 'string') + ->toHaveKey('properties.name.minLength', 3) + ->toHaveKey('properties.name.maxLength', 255) + ->toHaveKey('properties.email.type', 'string') + ->toHaveKey('properties.email.format', 'email') + ->toHaveKey('properties.age.type', 'integer') + ->toHaveKey('properties.age.minimum', 18) + ->toHaveKey('properties.age.maximum', 150) + ->toHaveKey('required', ['name', 'email']) + ->and($objectSchema->getPropertyKeys()) + ->toBe(['name', 'email', 'age']); // Validation tests expect(fn() => $objectSchema->validate([ @@ -101,13 +102,15 @@ 'email' => 'foo', ]); } catch (SchemaException $schemaException) { - expect($schemaException->getMessage())->toBe('The properties must match schema: email'); - expect($schemaException->getErrors())->toBe([ - '/email' => [ - "The data must match the 'email' format", - ], - ]); - expect($schemaException->getError())->toBeInstanceOf(ValidationError::class); + expect($schemaException->getMessage())->toBe('The properties must match schema: email') + ->and($schemaException->getErrors()) + ->toBe([ + '/email' => [ + "The data must match the 'email' format", + ], + ]) + ->and($schemaException->getError()) + ->toBeInstanceOf(ValidationError::class); throw $schemaException; } @@ -122,10 +125,10 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('properties.foo.type', 'string'); - expect($schemaArray)->toHaveKey('properties.foo.title', 'Foo'); - expect($schemaArray)->toHaveKey('properties.bar.type', 'string'); - expect($schemaArray['properties']['bar'])->not->toHaveKey('title'); + expect($schemaArray)->toHaveKey('properties.foo.type', 'string') + ->toHaveKey('properties.foo.title', 'Foo') + ->toHaveKey('properties.bar.type', 'string') + ->and($schemaArray['properties']['bar'])->not->toHaveKey('title'); }); it('can create an object schema with additional properties control', function (): void { @@ -139,8 +142,8 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('additionalProperties', false); - expect($schemaArray)->toHaveKey('required', ['name', 'type']); + expect($schemaArray)->toHaveKey('additionalProperties', false) + ->toHaveKey('required', ['name', 'type']); // Validation tests expect(fn() => $objectSchema->validate([ @@ -169,8 +172,8 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray['additionalProperties'])->toHaveKey('type', 'string'); - expect($schemaArray['additionalProperties'])->toHaveKey('minLength', 3); + expect($schemaArray['additionalProperties'])->toHaveKey('type', 'string') + ->toHaveKey('minLength', 3); // Validation tests - valid additional property expect(fn() => $objectSchema->validate([ @@ -213,8 +216,8 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('minProperties', 2); - expect($schemaArray)->toHaveKey('maxProperties', 3); + expect($schemaArray)->toHaveKey('minProperties', 2) + ->toHaveKey('maxProperties', 3); // Validation tests expect(fn() => $objectSchema->validate([ @@ -232,12 +235,11 @@ ]))->toThrow( SchemaException::class, 'Object must have at most 3 properties, 4 found', - ); - - expect(fn() => $objectSchema->validate([ - 'key1' => 'value1', - 'key2' => 'value2', - ]))->not->toThrow(SchemaException::class); + ) + ->and(fn() => $objectSchema->validate([ + 'key1' => 'value1', + 'key2' => 'value2', + ]))->not->toThrow(SchemaException::class); }); it('can specify a propertyNames schema', function (): void { @@ -274,9 +276,7 @@ // Check schema structure expect($schemaArray)->toHaveKey('patternProperties'); - expect($schemaArray['patternProperties'])->toHaveKey('^prefix_'); - expect($schemaArray['patternProperties'])->toHaveKey('^[A-Z][a-z]+$'); - expect($schemaArray['patternProperties'])->toHaveKey('^\d+$'); + expect($schemaArray['patternProperties'])->toHaveKeys(['^prefix_', '^[A-Z][a-z]+$', '^\d+$']); // Valid data tests expect(fn() => $objectSchema->validate([ @@ -309,8 +309,8 @@ // Check schema structure expect($schemaArray)->toHaveKey('properties'); - expect($schemaArray)->toHaveKey('patternProperties'); - expect($schemaArray)->toHaveKey('additionalProperties', false); + expect($schemaArray)->toHaveKey('patternProperties') + ->toHaveKey('additionalProperties', false); // Valid data expect(fn() => $objectSchema->validate([ @@ -350,8 +350,8 @@ // Should contain UnevaluatedProperties and DependentSchemas features $featureValues = array_map(fn($feature) => $feature->value, $features); - expect($featureValues)->toContain('unevaluatedProperties'); - expect($featureValues)->toContain('dependentSchemas'); + expect($featureValues)->toContain('unevaluatedProperties') + ->toContain('dependentSchemas'); // Verify features are properly deduplicated (no duplicates in array) expect($featureValues)->toBe(array_unique($featureValues)); @@ -443,15 +443,12 @@ // Should return an array expect($features)->toBeArray(); + expect($features) + ->toContainOnlyInstancesOf(SchemaFeature::class); // Should not be empty (has at least unevaluated properties feature) expect($features)->not->toBeEmpty(); - // Each item should be a SchemaFeature enum - foreach ($features as $feature) { - expect($feature)->toBeInstanceOf(SchemaFeature::class); - } - // Should be a regular indexed array, not associative expect(array_keys($features))->toBe(range(0, count($features) - 1)); }); @@ -474,8 +471,8 @@ // Should contain parent conditional features expect($featureValues)->toContain('if'); - expect($featureValues)->toContain('then'); - expect($featureValues)->toContain('else'); + expect($featureValues)->toContain('then') + ->toContain('else'); // Test that an ObjectSchema without parent features has fewer features $simpleObjectSchema = Schema::object('simple') @@ -486,8 +483,7 @@ // Simple object should not have conditional features expect($simpleFeatureValues)->not->toContain('if'); - expect($simpleFeatureValues)->not->toContain('then'); - expect($simpleFeatureValues)->not->toContain('else'); + expect($simpleFeatureValues)->not->toContain('then')->not->toContain('else'); // This proves that parent::getUsedFeatures() is necessary to collect these parent features }); @@ -595,11 +591,10 @@ ]))->toThrow( SchemaException::class, 'The required properties (age) are missing', - ); - - expect(fn() => $objectSchema->validate([ - 'name' => 'John Doe', - 'age' => 30, - 'email' => 'john@example.com', - ]))->not->toThrow(SchemaException::class); + ) + ->and(fn() => $objectSchema->validate([ + 'name' => 'John Doe', + 'age' => 30, + 'email' => 'john@example.com', + ]))->not->toThrow(SchemaException::class); }); diff --git a/tests/Unit/Types/StringSchemaTest.php b/tests/Unit/Types/StringSchemaTest.php index ed4d093..3c49052 100644 --- a/tests/Unit/Types/StringSchemaTest.php +++ b/tests/Unit/Types/StringSchemaTest.php @@ -22,12 +22,12 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); - expect($schemaArray)->toHaveKey('type', 'string'); - expect($schemaArray)->toHaveKey('title', 'username'); - expect($schemaArray)->toHaveKey('description', 'Username for the account'); - expect($schemaArray)->toHaveKey('minLength', 3); - expect($schemaArray)->toHaveKey('maxLength', 50); + expect($schemaArray)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema') + ->toHaveKey('type', 'string') + ->toHaveKey('title', 'username') + ->toHaveKey('description', 'Username for the account') + ->toHaveKey('minLength', 3) + ->toHaveKey('maxLength', 50); // Validation tests expect(fn() => $stringSchema->validate('ab'))->toThrow( @@ -38,9 +38,8 @@ expect(fn() => $stringSchema->validate(str_repeat('a', 51)))->toThrow( SchemaException::class, 'Maximum string length is 50, found 51', - ); - - expect(fn() => $stringSchema->validate('valid-username'))->not->toThrow(SchemaException::class); + ) + ->and(fn() => $stringSchema->validate('valid-username'))->not->toThrow(SchemaException::class); }); it('throws an exception if the minLength is greater than the maxLength', function (): void { @@ -67,11 +66,11 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'string'); - expect($schemaArray)->toHaveKey('title', 'password'); - expect($schemaArray)->toHaveKey('description', 'User password'); - expect($schemaArray)->toHaveKey('pattern', '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'); - expect($schemaArray)->toHaveKey('minLength', 8); + expect($schemaArray)->toHaveKey('type', 'string') + ->toHaveKey('title', 'password') + ->toHaveKey('description', 'User password') + ->toHaveKey('pattern', '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$') + ->toHaveKey('minLength', 8); // Validation tests expect(fn() => $stringSchema->validate('short1'))->toThrow( @@ -82,14 +81,10 @@ expect(fn() => $stringSchema->validate('onlyletters'))->toThrow( SchemaException::class, 'The string should match pattern: ^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', - ); - - expect(fn() => $stringSchema->validate('12345678'))->toThrow( - SchemaException::class, - 'The string should match pattern: ^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', - ); - - expect(fn() => $stringSchema->validate('password123'))->not->toThrow(SchemaException::class); + ) + ->and(fn() => $stringSchema->validate('12345678')) + ->toThrow(SchemaException::class, 'The string should match pattern: ^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$') + ->and(fn() => $stringSchema->validate('password123'))->not->toThrow(SchemaException::class); }); it('can create a string schema with format', function (): void { @@ -99,10 +94,10 @@ $schemaArray = $schema->toArray(); - expect($schemaArray)->toHaveKey('type', 'string'); - expect($schemaArray)->toHaveKey('title', 'email'); - expect($schemaArray)->toHaveKey('description', 'User email address'); - expect($schemaArray)->toHaveKey('format', 'email'); + expect($schemaArray)->toHaveKey('type', 'string') + ->toHaveKey('title', 'email') + ->toHaveKey('description', 'User email address') + ->toHaveKey('format', 'email'); // Validation tests expect(fn() => $schema->validate('not-an-email'))->toThrow( @@ -126,22 +121,21 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['string', 'null']); - expect($schemaArray)->toHaveKey('title', 'middle_name'); - expect($schemaArray)->toHaveKey('description', 'User middle name'); + expect($schemaArray)->toHaveKey('type', ['string', 'null']) + ->toHaveKey('title', 'middle_name') + ->toHaveKey('description', 'User middle name'); // Validation tests expect(fn() => $stringSchema->validate(null))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('John'))->not->toThrow(SchemaException::class); - - expect($stringSchema->isValid(null))->toBeTrue(); - expect($stringSchema->isValid('John'))->toBeTrue(); - - expect(fn() => $stringSchema->validate(123))->toThrow( - SchemaException::class, - 'The data (integer) must match the type: string, null', - ); - expect($stringSchema->isValid(123))->toBeFalse(); + expect(fn() => $stringSchema->validate('John'))->not->toThrow(SchemaException::class) + ->and($stringSchema->isValid(null)) + ->toBeTrue() + ->and($stringSchema->isValid('John')) + ->toBeTrue() + ->and(fn() => $stringSchema->validate(123)) + ->toThrow(SchemaException::class, 'The data (integer) must match the type: string, null') + ->and($stringSchema->isValid(123)) + ->toBeFalse(); }); it('can create a read-only string schema', function (): void { @@ -152,11 +146,11 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'string'); - expect($schemaArray)->toHaveKey('title', 'created_at'); - expect($schemaArray)->toHaveKey('description', 'Record creation timestamp'); - expect($schemaArray)->toHaveKey('format', 'date-time'); - expect($schemaArray)->toHaveKey('readOnly', true); + expect($schemaArray)->toHaveKey('type', 'string') + ->toHaveKey('title', 'created_at') + ->toHaveKey('description', 'Record creation timestamp') + ->toHaveKey('format', 'date-time') + ->toHaveKey('readOnly', true); // Validation tests expect(fn() => $stringSchema->validate('not-a-date'))->toThrow( @@ -174,10 +168,10 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('type', 'string'); - expect($schemaArray)->toHaveKey('title', 'status'); - expect($schemaArray)->toHaveKey('description', 'Current status of the record'); - expect($schemaArray)->toHaveKey('enum', ['draft', 'published', 'archived']); + expect($schemaArray)->toHaveKey('type', 'string') + ->toHaveKey('title', 'status') + ->toHaveKey('description', 'Current status of the record') + ->toHaveKey('enum', ['draft', 'published', 'archived']); // Validation tests expect(fn() => $stringSchema->validate('pending'))->toThrow( @@ -185,9 +179,9 @@ 'The data should match one item from enum', ); - expect(fn() => $stringSchema->validate('draft'))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('published'))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('archived'))->not->toThrow(SchemaException::class); + expect(fn() => $stringSchema->validate('draft'))->not->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate('published'))->not->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate('archived'))->not->toThrow(SchemaException::class); }); it('can create a nullable string schema with enum values', function (): void { @@ -198,10 +192,10 @@ $schemaArray = $stringSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['string', 'null']); - expect($schemaArray)->toHaveKey('title', 'priority'); - expect($schemaArray)->toHaveKey('description', 'Task priority level'); - expect($schemaArray)->toHaveKey('enum', ['low', 'medium', 'high', null]); + expect($schemaArray)->toHaveKey('type', ['string', 'null']) + ->toHaveKey('title', 'priority') + ->toHaveKey('description', 'Task priority level') + ->toHaveKey('enum', ['low', 'medium', 'high', null]); // Validation tests expect(fn() => $stringSchema->validate('critical'))->toThrow( @@ -209,10 +203,10 @@ 'The data should match one item from enum', ); - expect(fn() => $stringSchema->validate('low'))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('medium'))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate('high'))->not->toThrow(SchemaException::class); - expect(fn() => $stringSchema->validate(null))->not->toThrow(SchemaException::class); + expect(fn() => $stringSchema->validate('low'))->not->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate('medium'))->not->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate('high'))->not->toThrow(SchemaException::class) + ->and(fn() => $stringSchema->validate(null))->not->toThrow(SchemaException::class); }); it('can mark a string schema as deprecated', function (): void { @@ -244,10 +238,10 @@ $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'); + expect($schemaArray)->toHaveKey('contentEncoding', 'base64') + ->toHaveKey('contentMediaType', 'application/json') + ->toHaveKey('contentSchema.type', 'object') + ->toHaveKey('contentSchema.properties.name.type', 'string'); // Valid base64 encoded string that matches the content schema expect(fn() => $stringSchema->validate(base64_encode('{"name":"Ada"}'))) @@ -289,15 +283,15 @@ $contentFeatures = $reflectionMethod->invoke($stringSchema); - expect($contentFeatures)->toContain(SchemaFeature::ContentEncoding); - expect($contentFeatures)->toContain(SchemaFeature::ContentMediaType); - expect($contentFeatures)->toContain(SchemaFeature::ContentSchema); + expect($contentFeatures)->toContain(SchemaFeature::ContentEncoding) + ->toContain(SchemaFeature::ContentMediaType) + ->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); + expect($allFeatures)->toContain(SchemaFeature::ContentEncoding) + ->toContain(SchemaFeature::ContentMediaType) + ->toContain(SchemaFeature::ContentSchema); }); diff --git a/tests/Unit/Types/TypelessSchemaTest.php b/tests/Unit/Types/TypelessSchemaTest.php index 8333aaf..d80ac4a 100644 --- a/tests/Unit/Types/TypelessSchemaTest.php +++ b/tests/Unit/Types/TypelessSchemaTest.php @@ -26,9 +26,10 @@ $schemaArray = $typelessSchema->toArray(); - expect($schemaArray)->not->toHaveKey('type'); - expect($schemaArray)->toHaveKey('title', 'shape'); - expect($schemaArray['oneOf'])->toHaveCount(2); + expect($schemaArray)->not->toHaveKey('type') + ->toHaveKey('title', 'shape') + ->and($schemaArray['oneOf']) + ->toHaveCount(2); }); it('can create a typeless definition-only schema', function (): void { @@ -40,13 +41,14 @@ $schemaArray = $typelessSchema->toArray(); - expect($schemaArray)->not->toHaveKey('type'); - expect($schemaArray['$defs']['address']['type'])->toBe('object'); + expect($schemaArray)->not->toHaveKey('type') + ->and($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'); + expect($typelessSchema)->toBeInstanceOf(TypelessSchema::class) + ->and($typelessSchema->toArray())->not->toHaveKey('type'); }); diff --git a/tests/Unit/Types/UnevaluatedItemsTest.php b/tests/Unit/Types/UnevaluatedItemsTest.php index 566b8f1..a276f54 100644 --- a/tests/Unit/Types/UnevaluatedItemsTest.php +++ b/tests/Unit/Types/UnevaluatedItemsTest.php @@ -17,8 +17,8 @@ $schemaArray = $arraySchema->toArray(); - expect($schemaArray)->toHaveKey('unevaluatedItems', false); - expect($schemaArray)->toHaveKey('items'); + expect($schemaArray)->toHaveKey('unevaluatedItems', false) + ->toHaveKey('items'); // Test basic validation (unevaluatedItems validation requires a full JSON Schema validator) expect($arraySchema->isValid(['hello', 'world']))->toBeTrue(); @@ -52,11 +52,12 @@ $schemaArray = $arraySchema->toArray(); - expect($schemaArray)->toHaveKey('unevaluatedItems'); - expect($schemaArray['unevaluatedItems'])->toBe([ - 'type' => 'integer', - 'minimum' => 0, - ]); + expect($schemaArray)->toHaveKey('unevaluatedItems') + ->and($schemaArray['unevaluatedItems']) + ->toBe([ + 'type' => 'integer', + 'minimum' => 0, + ]); // Test basic validation expect($arraySchema->isValid(['hello']))->toBeTrue(); @@ -80,8 +81,8 @@ ->items(Schema::string()) ->unevaluatedItems(false); - expect($arraySchema->toArray())->toHaveKey('unevaluatedItems', false); - expect($arraySchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); + expect($arraySchema->toArray())->toHaveKey('unevaluatedItems', false) + ->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); }); it('works with Draft 2020-12', function (): void { @@ -89,8 +90,8 @@ ->items(Schema::string()) ->unevaluatedItems(false); - expect($arraySchema->toArray())->toHaveKey('unevaluatedItems', false); - expect($arraySchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); + expect($arraySchema->toArray())->toHaveKey('unevaluatedItems', false) + ->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); }); it('detects unevaluatedItems feature correctly', function (): void { @@ -134,13 +135,12 @@ $schemaArray = $arraySchema->toArray(); - expect($schemaArray)->toHaveKey('items'); - expect($schemaArray)->toHaveKey('contains'); - expect($schemaArray)->toHaveKey('minContains', 1); - expect($schemaArray)->toHaveKey('maxContains', 5); - expect($schemaArray)->toHaveKey('unevaluatedItems', false); - expect($schemaArray)->toHaveKey('minItems', 2); - expect($schemaArray)->toHaveKey('maxItems', 10); + expect($schemaArray)->toHaveKeys(['items', 'contains']) + ->toHaveKey('minContains', 1) + ->toHaveKey('maxContains', 5) + ->toHaveKey('unevaluatedItems', false) + ->toHaveKey('minItems', 2) + ->toHaveKey('maxItems', 10); }); it('validates version during schema output', function (): void { @@ -172,13 +172,14 @@ // Verify schema structure expect($schemaArray)->toHaveKey('items'); - expect($schemaArray)->toHaveKey('contains'); - expect($schemaArray)->toHaveKey('minContains', 1); - expect($schemaArray)->toHaveKey('unevaluatedItems'); - expect($schemaArray['unevaluatedItems'])->toBe([ - 'type' => 'integer', - 'minimum' => 0, - ]); + expect($schemaArray)->toHaveKey('contains') + ->toHaveKey('minContains', 1) + ->toHaveKey('unevaluatedItems') + ->and($schemaArray['unevaluatedItems']) + ->toBe([ + 'type' => 'integer', + 'minimum' => 0, + ]); // Test basic validation (items validation still works) expect($arraySchema->isValid(['hello', 'world']))->toBeTrue(); diff --git a/tests/Unit/Types/UnevaluatedPropertiesTest.php b/tests/Unit/Types/UnevaluatedPropertiesTest.php index 503a62d..24b881c 100644 --- a/tests/Unit/Types/UnevaluatedPropertiesTest.php +++ b/tests/Unit/Types/UnevaluatedPropertiesTest.php @@ -20,9 +20,9 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('unevaluatedProperties', false); - expect($schemaArray)->toHaveKey('properties.name'); - expect($schemaArray)->toHaveKey('properties.email'); + expect($schemaArray)->toHaveKey('unevaluatedProperties', false) + ->toHaveKey('properties.name') + ->toHaveKey('properties.email'); // Test basic validation (unevaluatedProperties validation requires a full JSON Schema validator) expect($objectSchema->isValid([ @@ -65,11 +65,12 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('unevaluatedProperties'); - expect($schemaArray['unevaluatedProperties'])->toBe([ - 'type' => 'string', - 'minLength' => 3, - ]); + expect($schemaArray)->toHaveKey('unevaluatedProperties') + ->and($schemaArray['unevaluatedProperties']) + ->toBe([ + 'type' => 'string', + 'minLength' => 3, + ]); // Test basic validation expect($objectSchema->isValid([ @@ -97,8 +98,8 @@ ) ->unevaluatedProperties(false); - expect($objectSchema->toArray())->toHaveKey('unevaluatedProperties', false); - expect($objectSchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); + expect($objectSchema->toArray())->toHaveKey('unevaluatedProperties', false) + ->toHaveKey('$schema', 'https://json-schema.org/draft/2019-09/schema'); }); it('works with Draft 2020-12', function (): void { @@ -108,8 +109,8 @@ ) ->unevaluatedProperties(false); - expect($objectSchema->toArray())->toHaveKey('unevaluatedProperties', false); - expect($objectSchema->toArray())->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); + expect($objectSchema->toArray())->toHaveKey('unevaluatedProperties', false) + ->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); }); it('detects unevaluatedProperties feature correctly', function (): void { @@ -158,12 +159,12 @@ $schemaArray = $objectSchema->toArray(); - expect($schemaArray)->toHaveKey('properties'); - expect($schemaArray)->toHaveKey('required', ['name', 'email']); - expect($schemaArray)->toHaveKey('additionalProperties', true); - expect($schemaArray)->toHaveKey('unevaluatedProperties', false); - expect($schemaArray)->toHaveKey('minProperties', 2); - expect($schemaArray)->toHaveKey('maxProperties', 10); + expect($schemaArray)->toHaveKey('properties') + ->toHaveKey('required', ['name', 'email']) + ->toHaveKey('additionalProperties', true) + ->toHaveKey('unevaluatedProperties', false) + ->toHaveKey('minProperties', 2) + ->toHaveKey('maxProperties', 10); }); it('validates version during schema output', function (): void { @@ -199,13 +200,14 @@ // Verify schema structure expect($schemaArray)->toHaveKey('properties'); - expect($schemaArray)->toHaveKey('required', ['name']); - expect($schemaArray)->toHaveKey('additionalProperties', true); - expect($schemaArray)->toHaveKey('unevaluatedProperties'); - expect($schemaArray['unevaluatedProperties'])->toBe([ - 'type' => 'string', - 'minLength' => 2, - ]); + expect($schemaArray)->toHaveKey('required', ['name']) + ->toHaveKey('additionalProperties', true) + ->toHaveKey('unevaluatedProperties') + ->and($schemaArray['unevaluatedProperties']) + ->toBe([ + 'type' => 'string', + 'minLength' => 2, + ]); // Test basic validation (defined properties) expect($objectSchema->isValid([ diff --git a/tests/Unit/Types/UnionSchemaTest.php b/tests/Unit/Types/UnionSchemaTest.php index 69d3b60..4f6358b 100644 --- a/tests/Unit/Types/UnionSchemaTest.php +++ b/tests/Unit/Types/UnionSchemaTest.php @@ -17,16 +17,17 @@ $schemaArray = $unionSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['string', 'integer']); - expect($schemaArray)->toHaveKey('title', 'id'); - expect($schemaArray)->toHaveKey('description', 'ID can be either a string or an integer'); + expect($schemaArray)->toHaveKey('type', ['string', 'integer']) + ->toHaveKey('title', 'id') + ->toHaveKey('description', 'ID can be either a string or an integer'); // Test validation expect(fn() => $unionSchema->validate('abc123'))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(123))->not->toThrow(SchemaException::class); - - expect(fn() => $unionSchema->validate(true))->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(null))->toThrow(SchemaException::class); + expect(fn() => $unionSchema->validate(123))->not->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(true)) + ->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(null)) + ->toThrow(SchemaException::class); }); it('can create a nullable union schema', function (): void { @@ -39,10 +40,10 @@ // Test validation expect(fn() => $unionSchema->validate('abc'))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(123.45))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(null))->not->toThrow(SchemaException::class); - - expect(fn() => $unionSchema->validate(true))->toThrow(SchemaException::class); + expect(fn() => $unionSchema->validate(123.45))->not->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(null))->not->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(true)) + ->toThrow(SchemaException::class); }); it('can create a union schema with enum values', function (): void { @@ -51,17 +52,18 @@ $schemaArray = $unionSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['string', 'integer']); - expect($schemaArray)->toHaveKey('enum', ['pending', 'active', 1, 2]); + expect($schemaArray)->toHaveKey('type', ['string', 'integer']) + ->toHaveKey('enum', ['pending', 'active', 1, 2]); // Test validation expect(fn() => $unionSchema->validate('pending'))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate('active'))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(1))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(2))->not->toThrow(SchemaException::class); - - expect(fn() => $unionSchema->validate('invalid'))->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(3))->toThrow(SchemaException::class); + expect(fn() => $unionSchema->validate('active'))->not->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(1))->not->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(2))->not->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate('invalid')) + ->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(3)) + ->toThrow(SchemaException::class); }); it('can create a union schema with const value', function (): void { @@ -70,13 +72,14 @@ $schemaArray = $unionSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['string', 'integer']); - expect($schemaArray)->toHaveKey('const', 'test'); + expect($schemaArray)->toHaveKey('type', ['string', 'integer']) + ->toHaveKey('const', 'test'); // Test validation expect(fn() => $unionSchema->validate('test'))->not->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate('other'))->toThrow(SchemaException::class); - expect(fn() => $unionSchema->validate(123))->toThrow(SchemaException::class); + expect(fn() => $unionSchema->validate('other'))->toThrow(SchemaException::class) + ->and(fn() => $unionSchema->validate(123)) + ->toThrow(SchemaException::class); }); it('can create a union schema with metadata', function (): void { @@ -87,10 +90,10 @@ $schemaArray = $unionSchema->toArray(); - expect($schemaArray)->toHaveKey('type', ['string', 'number']); - expect($schemaArray)->toHaveKey('description', 'A mixed type field'); - expect($schemaArray)->toHaveKey('default', 'test'); - expect($schemaArray)->toHaveKey('readOnly', true); + expect($schemaArray)->toHaveKey('type', ['string', 'number']) + ->toHaveKey('description', 'A mixed type field') + ->toHaveKey('default', 'test') + ->toHaveKey('readOnly', true); }); it('throws exception when creating union schema with no types', function (): void { diff --git a/tests/Unit/VersionFeatureValidationTest.php b/tests/Unit/VersionFeatureValidationTest.php index 16aa19c..99783e0 100644 --- a/tests/Unit/VersionFeatureValidationTest.php +++ b/tests/Unit/VersionFeatureValidationTest.php @@ -29,8 +29,8 @@ $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); - expect(fn(): StringSchema => $draft201909Schema->if($conditionSchema))->not->toThrow(SchemaException::class); - expect(fn(): StringSchema => $draft202012Schema->if($conditionSchema))->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft201909Schema->if($conditionSchema))->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft202012Schema->if($conditionSchema))->not->toThrow(SchemaException::class); }); it('outputs version-appropriate definition keywords', function (): void { @@ -42,8 +42,7 @@ $draft06Array = $draft06Schema->toArray(); - expect($draft06Array)->toHaveKey('definitions'); - expect($draft06Array)->not->toHaveKey('$defs'); + expect($draft06Array)->toHaveKey('definitions')->not->toHaveKey('$defs'); // Draft 07 should use 'definitions' $objectSchema = Schema::object('test', SchemaVersion::Draft_07); @@ -51,8 +50,7 @@ $draft07Array = $objectSchema->toArray(); - expect($draft07Array)->toHaveKey('definitions'); - expect($draft07Array)->not->toHaveKey('$defs'); + expect($draft07Array)->toHaveKey('definitions')->not->toHaveKey('$defs'); // Draft 2019-09+ should use '$defs' $draft201909Schema = Schema::object('test', SchemaVersion::Draft_2019_09); @@ -60,8 +58,7 @@ $draft201909Array = $draft201909Schema->toArray(); - expect($draft201909Array)->toHaveKey('$defs'); - expect($draft201909Array)->not->toHaveKey('definitions'); + expect($draft201909Array)->toHaveKey('$defs')->not->toHaveKey('definitions'); // Draft 2020-12 should also use '$defs' $draft202012Schema = Schema::object('test', SchemaVersion::Draft_2020_12); @@ -69,8 +66,7 @@ $draft202012Array = $draft202012Schema->toArray(); - expect($draft202012Array)->toHaveKey('$defs'); - expect($draft202012Array)->not->toHaveKey('definitions'); + expect($draft202012Array)->toHaveKey('$defs')->not->toHaveKey('definitions'); }); it('validates features during schema output', function (): void { @@ -85,8 +81,7 @@ expect(fn(): array => $stringSchema->toArray())->not->toThrow(SchemaException::class); $output = $stringSchema->toArray(); - expect($output)->toHaveKey('if'); - expect($output)->toHaveKey('then'); + expect($output)->toHaveKeys(['if', 'then']); }); it('provides helpful error messages for unsupported features', function (): void { @@ -164,9 +159,9 @@ $features = $reflectionMethod->invoke($stringSchema); - expect($features)->toContain(SchemaFeature::If); - expect($features)->toContain(SchemaFeature::Then); - expect($features)->toContain(SchemaFeature::IfThenElse); + expect($features)->toContain(SchemaFeature::If) + ->toContain(SchemaFeature::Then) + ->toContain(SchemaFeature::IfThenElse); // Test with if-else construct $schema2 = Schema::string('test2', SchemaVersion::Draft_07); @@ -174,9 +169,9 @@ $features2 = $reflectionMethod->invoke($schema2); - expect($features2)->toContain(SchemaFeature::If); - expect($features2)->toContain(SchemaFeature::Else); - expect($features2)->toContain(SchemaFeature::IfThenElse); + expect($features2)->toContain(SchemaFeature::If) + ->toContain(SchemaFeature::Else) + ->toContain(SchemaFeature::IfThenElse); // Test with complete if-then-else construct $schema3 = Schema::string('test3', SchemaVersion::Draft_07); @@ -184,10 +179,10 @@ $features3 = $reflectionMethod->invoke($schema3); - expect($features3)->toContain(SchemaFeature::If); - expect($features3)->toContain(SchemaFeature::Then); - expect($features3)->toContain(SchemaFeature::Else); - expect($features3)->toContain(SchemaFeature::IfThenElse); + expect($features3)->toContain(SchemaFeature::If) + ->toContain(SchemaFeature::Then) + ->toContain(SchemaFeature::Else) + ->toContain(SchemaFeature::IfThenElse); }); it('validates deprecated feature against schema version', function (): void { @@ -203,8 +198,8 @@ $draft201909Schema = Schema::string('test', SchemaVersion::Draft_2019_09); $draft202012Schema = Schema::string('test', SchemaVersion::Draft_2020_12); - expect(fn(): StringSchema => $draft201909Schema->deprecated())->not->toThrow(SchemaException::class); - expect(fn(): StringSchema => $draft202012Schema->deprecated())->not->toThrow(SchemaException::class); + expect(fn(): StringSchema => $draft201909Schema->deprecated())->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft202012Schema->deprecated())->not->toThrow(SchemaException::class); }); it('validates comment feature against schema version', function (): void { @@ -221,9 +216,9 @@ $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); + expect(fn(): StringSchema => $draft07Schema->comment('Allowed'))->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft201909Schema->comment('Allowed'))->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft202012Schema->comment('Allowed'))->not->toThrow(SchemaException::class); }); it('validates read/write features against schema version', function (): void { @@ -233,23 +228,24 @@ 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.', - ); + ) + ->and(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); + expect(fn(): StringSchema => $draft07Schema->readOnly())->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft07Schema->writeOnly())->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft201909Schema->readOnly())->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft201909Schema->writeOnly())->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft202012Schema->readOnly())->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft202012Schema->writeOnly())->not->toThrow(SchemaException::class); }); it('validates array contains count features against schema version', function (): void { @@ -259,20 +255,21 @@ expect(fn(): ArraySchema => $arraySchema->minContains(1))->toThrow( SchemaException::class, 'Feature "Minimum number of contains matches" is not supported in Draft 7. Minimum version required: Draft 2019-09.', - ); - expect(fn(): ArraySchema => $arraySchema->maxContains(5))->toThrow( - SchemaException::class, - 'Feature "Maximum number of contains matches" is not supported in Draft 7. Minimum version required: Draft 2019-09.', - ); + ) + ->and(fn(): ArraySchema => $arraySchema->maxContains(5)) + ->toThrow( + SchemaException::class, + 'Feature "Maximum number of contains matches" is not supported in Draft 7. Minimum version required: Draft 2019-09.', + ); // Draft 2019-09+ should accept contains count features $draft201909Array = Schema::array('test', SchemaVersion::Draft_2019_09); $draft202012Array = Schema::array('test', SchemaVersion::Draft_2020_12); - expect(fn(): ArraySchema => $draft201909Array->minContains(1))->not->toThrow(SchemaException::class); - expect(fn(): ArraySchema => $draft201909Array->maxContains(5))->not->toThrow(SchemaException::class); - expect(fn(): ArraySchema => $draft202012Array->minContains(1))->not->toThrow(SchemaException::class); - expect(fn(): ArraySchema => $draft202012Array->maxContains(5))->not->toThrow(SchemaException::class); + expect(fn(): ArraySchema => $draft201909Array->minContains(1))->not->toThrow(SchemaException::class) + ->and(fn(): ArraySchema => $draft201909Array->maxContains(5))->not->toThrow(SchemaException::class) + ->and(fn(): ArraySchema => $draft202012Array->minContains(1))->not->toThrow(SchemaException::class) + ->and(fn(): ArraySchema => $draft202012Array->maxContains(5))->not->toThrow(SchemaException::class); }); it('detects metadata and read/write features correctly', function (): void { @@ -289,17 +286,18 @@ $metadataFeatures = $reflectionMethod->invoke($stringSchema); $readWriteFeatures = $getReadWriteMethod->invoke($stringSchema); - expect($metadataFeatures)->toContain(SchemaFeature::Deprecated); - expect($metadataFeatures)->toContain(SchemaFeature::Comment); - expect($readWriteFeatures)->toContain(SchemaFeature::ReadOnly); + expect($metadataFeatures)->toContain(SchemaFeature::Deprecated) + ->toContain(SchemaFeature::Comment) + ->and($readWriteFeatures) + ->toContain(SchemaFeature::ReadOnly); // Test that features are included in overall feature detection $getUsedMethod = $reflection->getMethod('getUsedFeatures'); $allFeatures = $getUsedMethod->invoke($stringSchema); - expect($allFeatures)->toContain(SchemaFeature::Deprecated); - expect($allFeatures)->toContain(SchemaFeature::Comment); - expect($allFeatures)->toContain(SchemaFeature::ReadOnly); + expect($allFeatures)->toContain(SchemaFeature::Deprecated) + ->toContain(SchemaFeature::Comment) + ->toContain(SchemaFeature::ReadOnly); }); it('detects array contains count features correctly', function (): void { @@ -311,33 +309,36 @@ $features = $reflectionMethod->invoke($arraySchema); - expect($features)->toContain(SchemaFeature::MinContains); - expect($features)->toContain(SchemaFeature::MaxContains); + expect($features)->toContain(SchemaFeature::MinContains) + ->toContain(SchemaFeature::MaxContains); // Test that features are included in overall feature detection $getUsedMethod = $reflection->getMethod('getUsedFeatures'); $allFeatures = $getUsedMethod->invoke($arraySchema); - expect($allFeatures)->toContain(SchemaFeature::MinContains); - expect($allFeatures)->toContain(SchemaFeature::MaxContains); + expect($allFeatures)->toContain(SchemaFeature::MinContains) + ->toContain(SchemaFeature::MaxContains); }); 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); + expect(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Date))->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Time)) + ->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Regex)) + ->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft06Schema->format(SchemaFormat::RelativeJsonPointer)) + ->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft06Schema->format(SchemaFormat::IdnEmail)) + ->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft06Schema->format(SchemaFormat::IdnHostname)) + ->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft06Schema->format(SchemaFormat::Iri)) + ->toThrow(SchemaException::class) + ->and(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); @@ -349,12 +350,12 @@ expect(fn(): StringSchema => $stringSchema->format(SchemaFormat::Duration))->toThrow( SchemaException::class, 'Feature "ISO 8601 duration format validation" is not supported in Draft 7. Minimum version required: Draft 2019-09.', - ); - - expect(fn(): StringSchema => $stringSchema->format(SchemaFormat::Uuid))->toThrow( - SchemaException::class, - 'Feature "RFC 4122 UUID format validation" is not supported in Draft 7. Minimum version required: Draft 2019-09.', - ); + ) + ->and(fn(): StringSchema => $stringSchema->format(SchemaFormat::Uuid)) + ->toThrow( + SchemaException::class, + 'Feature "RFC 4122 UUID format validation" is not supported in Draft 7. Minimum version required: Draft 2019-09.', + ); // Draft 07 should accept other formats like email expect(fn(): StringSchema => $stringSchema->format(SchemaFormat::Email))->not->toThrow(SchemaException::class); @@ -366,12 +367,16 @@ expect(fn(): StringSchema => $draft201909Schema->format(SchemaFormat::Duration))->not->toThrow( SchemaException::class, - ); - expect(fn(): StringSchema => $draft201909Schema->format(SchemaFormat::Uuid))->not->toThrow(SchemaException::class); - expect(fn(): StringSchema => $draft202012Schema->format(SchemaFormat::Duration))->not->toThrow( - SchemaException::class, - ); - expect(fn(): StringSchema => $draft202012Schema->format(SchemaFormat::Uuid))->not->toThrow(SchemaException::class); + ) + ->and(fn(): StringSchema => $draft201909Schema->format(SchemaFormat::Uuid))->not->toThrow( + SchemaException::class, + ) + ->and(fn(): StringSchema => $draft202012Schema->format(SchemaFormat::Duration))->not->toThrow( + SchemaException::class, + ) + ->and(fn(): StringSchema => $draft202012Schema->format(SchemaFormat::Uuid))->not->toThrow( + SchemaException::class, + ); }); it('detects format features correctly', function (): void { @@ -418,39 +423,40 @@ 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.', - ); + ) + ->and(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, - ); + expect(fn(): StringSchema => $draft07Schema->contentEncoding('base64'))->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft07Schema->contentMediaType('application/json'))->not->toThrow( + SchemaException::class, + ) + ->and(fn(): StringSchema => $draft201909Schema->contentEncoding('base64'))->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $draft201909Schema->contentMediaType('application/json'))->not->toThrow( + SchemaException::class, + ) + ->and(fn(): StringSchema => $draft202012Schema->contentEncoding('base64'))->not->toThrow(SchemaException::class) + ->and(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); - expect(fn(): StringSchema => $stringSchema->format('custom-format'))->not->toThrow(SchemaException::class); - expect(fn(): StringSchema => $stringSchema->format('duration'))->not->toThrow( - SchemaException::class, - ); // String, not enum + expect(fn(): StringSchema => $stringSchema->format('custom-format'))->not->toThrow(SchemaException::class) + ->and(fn(): StringSchema => $stringSchema->format('duration'))->not->toThrow( + SchemaException::class, + ); // String, not enum // Verify that string formats don't add features $reflection = new ReflectionClass($stringSchema); diff --git a/tests/Unit/VersionSupportTest.php b/tests/Unit/VersionSupportTest.php index afed95f..535cb7d 100644 --- a/tests/Unit/VersionSupportTest.php +++ b/tests/Unit/VersionSupportTest.php @@ -16,24 +16,33 @@ }); 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'); + expect(SchemaVersion::Draft_06->value)->toBe('http://json-schema.org/draft-06/schema#') + ->and(SchemaVersion::Draft_07->value) + ->toBe('http://json-schema.org/draft-07/schema#') + ->and(SchemaVersion::Draft_2019_09->value) + ->toBe('https://json-schema.org/draft/2019-09/schema') + ->and(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'); + expect(SchemaVersion::Draft_06->getName())->toBe('Draft 6') + ->and(SchemaVersion::Draft_07->getName()) + ->toBe('Draft 7') + ->and(SchemaVersion::Draft_2019_09->getName()) + ->toBe('Draft 2019-09') + ->and(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); + expect(SchemaVersion::Draft_06->getYear())->toBe(2017) + ->and(SchemaVersion::Draft_07->getYear()) + ->toBe(2018) + ->and(SchemaVersion::Draft_2019_09->getYear()) + ->toBe(2019) + ->and(SchemaVersion::Draft_2020_12->getYear()) + ->toBe(2020); }); it('has correct schema version feature support', function (): void { @@ -44,104 +53,155 @@ // 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(); + expect($draft07->supports(SchemaFeature::IfThenElse))->toBeTrue() + ->and($draft201909->supports(SchemaFeature::IfThenElse)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::IfThenElse)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::ContentMediaType)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::ContentMediaType)) + ->toBeTrue() + ->and($draft201909->supports(SchemaFeature::ContentMediaType)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::ContentMediaType)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::Comment)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::Comment)) + ->toBeTrue() + ->and($draft201909->supports(SchemaFeature::Comment)) + ->toBeTrue() + ->and($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(); + expect($draft07->supports(SchemaFeature::Anchor))->toBeFalse() + ->and($draft201909->supports(SchemaFeature::Anchor)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::Anchor)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::Defs)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::Defs)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::Defs)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::Defs)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::UnevaluatedProperties)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::UnevaluatedProperties)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::UnevaluatedProperties)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::UnevaluatedProperties)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::DependentRequired)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::DependentRequired)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::DependentRequired)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::DependentRequired)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::ContentSchema)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::ContentSchema)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::ContentSchema)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::ContentSchema)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::Deprecated)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::Deprecated)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::Deprecated)) + ->toBeTrue() + ->and($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 + expect($draft07->supports(SchemaFeature::RecursiveRefLegacy))->toBeFalse() + ->and($draft201909->supports(SchemaFeature::RecursiveRefLegacy)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::RecursiveRefLegacy)) + ->toBeFalse() + ->and($draft06->supports(SchemaFeature::RecursiveRef)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::RecursiveRef)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::RecursiveRef)) + ->toBeTrue() + ->and($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(); + expect($draft07->supports(SchemaFeature::DynamicRef))->toBeFalse() + ->and($draft201909->supports(SchemaFeature::DynamicRef)) + ->toBeFalse() + ->and($draft202012->supports(SchemaFeature::DynamicRef)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::PrefixItems)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::PrefixItems)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::PrefixItems)) + ->toBeFalse() + ->and($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(); + expect($draft07->supports(SchemaFeature::FormatAnnotation))->toBeFalse() + ->and($draft201909->supports(SchemaFeature::FormatAnnotation)) + ->toBeFalse() + ->and($draft202012->supports(SchemaFeature::FormatAnnotation)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::UnevaluatedVocabulary)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::UnevaluatedVocabulary)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::UnevaluatedVocabulary)) + ->toBeFalse() + ->and($draft202012->supports(SchemaFeature::UnevaluatedVocabulary)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::UnicodeRegex)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::UnicodeRegex)) + ->toBeFalse() + ->and($draft201909->supports(SchemaFeature::UnicodeRegex)) + ->toBeFalse() + ->and($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(); + expect($draft07->supports(SchemaFeature::FormatDate))->toBeTrue() + ->and($draft201909->supports(SchemaFeature::FormatDate)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::FormatDate)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::FormatIdnEmail)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::FormatIdnEmail)) + ->toBeTrue() + ->and($draft201909->supports(SchemaFeature::FormatIdnEmail)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::FormatIdnEmail)) + ->toBeTrue() + ->and($draft06->supports(SchemaFeature::FormatIdnHostname)) + ->toBeFalse() + ->and($draft07->supports(SchemaFeature::FormatIdnHostname)) + ->toBeTrue() + ->and($draft201909->supports(SchemaFeature::FormatIdnHostname)) + ->toBeTrue() + ->and($draft202012->supports(SchemaFeature::FormatIdnHostname)) + ->toBeTrue(); }); it('supports enum-based feature checks', function (): void { @@ -152,36 +212,45 @@ expect($draft202012->supports(SchemaFeature::UnicodeRegex))->toBeTrue(); $draft07 = SchemaVersion::Draft_07; - expect($draft07->supports(SchemaFeature::PrefixItems))->toBeFalse(); - expect($draft07->supports(SchemaFeature::IfThenElse))->toBeTrue(); + expect($draft07->supports(SchemaFeature::PrefixItems))->toBeFalse() + ->and($draft07->supports(SchemaFeature::IfThenElse)) + ->toBeTrue(); }); it('provides feature metadata through enum', function (): void { $feature = SchemaFeature::PrefixItems; - expect($feature->getMinimumVersion())->toBe(SchemaVersion::Draft_2020_12); - expect($feature->getMaximumVersion())->toBeNull(); - expect($feature->getDescription())->toContain('tuple'); - expect($feature->wasIntroducedIn(SchemaVersion::Draft_2020_12))->toBeTrue(); - expect($feature->wasIntroducedIn(SchemaVersion::Draft_2019_09))->toBeFalse(); - expect($feature->wasRemovedIn(SchemaVersion::Draft_2020_12))->toBeFalse(); + expect($feature->getMinimumVersion())->toBe(SchemaVersion::Draft_2020_12) + ->and($feature->getMaximumVersion()) + ->toBeNull() + ->and($feature->getDescription()) + ->toContain('tuple') + ->and($feature->wasIntroducedIn(SchemaVersion::Draft_2020_12)) + ->toBeTrue() + ->and($feature->wasIntroducedIn(SchemaVersion::Draft_2019_09)) + ->toBeFalse() + ->and($feature->wasRemovedIn(SchemaVersion::Draft_2020_12)) + ->toBeFalse(); // Test a feature that was removed $recursiveFeature = SchemaFeature::RecursiveRef; - expect($recursiveFeature->getMaximumVersion())->toBe(SchemaVersion::Draft_2019_09); - expect($recursiveFeature->wasRemovedIn(SchemaVersion::Draft_2020_12))->toBeTrue(); + expect($recursiveFeature->getMaximumVersion())->toBe(SchemaVersion::Draft_2019_09) + ->and($recursiveFeature->wasRemovedIn(SchemaVersion::Draft_2020_12)) + ->toBeTrue(); }); it('has correct default and latest versions', function (): void { - expect(SchemaVersion::default())->toBe(SchemaVersion::Draft_2020_12); - expect(SchemaVersion::latest())->toBe(SchemaVersion::Draft_2020_12); + expect(SchemaVersion::default())->toBe(SchemaVersion::Draft_2020_12) + ->and(SchemaVersion::latest()) + ->toBe(SchemaVersion::Draft_2020_12); }); it('can create schema factory with version parameter', function (): void { $stringSchema = Schema::string('test', SchemaVersion::Draft_2020_12); - expect($stringSchema)->toBeInstanceOf(StringSchema::class); - expect($stringSchema->getVersion())->toBe(SchemaVersion::Draft_2020_12); + expect($stringSchema)->toBeInstanceOf(StringSchema::class) + ->and($stringSchema->getVersion()) + ->toBe(SchemaVersion::Draft_2020_12); }); it('can manage schema factory default version', function (): void { @@ -209,9 +278,11 @@ $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'); + expect($draft06Array['$schema'])->toBe('http://json-schema.org/draft-06/schema#') + ->and($draft07Array['$schema']) + ->toBe('http://json-schema.org/draft-07/schema#') + ->and($draft202012Array['$schema']) + ->toBe('https://json-schema.org/draft/2020-12/schema'); }); it('can change schema version on existing schema', function (): void { @@ -273,6 +344,7 @@ $arrayWithoutRef = $stringSchema->toArray(false); $arrayWithRef = $stringSchema->toArray(true); - expect($arrayWithoutRef)->not->toHaveKey('$schema'); - expect($arrayWithRef)->toHaveKey('$schema', SchemaVersion::Draft_2020_12->value); + expect($arrayWithoutRef)->not->toHaveKey('$schema') + ->and($arrayWithRef) + ->toHaveKey('$schema', SchemaVersion::Draft_2020_12->value); }); From d9fae86fc70ac844ba5a362b44e312b1e2fed294 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Sun, 2 Aug 2026 23:39:54 +0100 Subject: [PATCH 2/8] stan --- phpstan.dist.neon | 1 + rector.php | 4 +- src/Converters/EnumConverter.php | 4 +- tests/Support/SchemaRoundTrip.php | 4 +- tests/Unit/ConditionalSchemaTest.php | 16 +- tests/Unit/Converters/ClassConverterTest.php | 74 +++++---- .../Unit/Converters/ClosureConverterTest.php | 54 +++--- .../Converters/JsonConverterFixturesTest.php | 5 + tests/Unit/Converters/JsonConverterTest.php | 102 +++++++++--- tests/Unit/DefinitionsSchemaTest.php | 12 +- tests/Unit/Enums/SchemaTypeTest.php | 2 +- tests/Unit/SchemaTest.php | 29 ++-- tests/Unit/Support/DocParserTest.php | 155 +++++++++--------- tests/Unit/Types/ArraySchemaTest.php | 52 ++++-- tests/Unit/Types/DependentSchemasTest.php | 22 ++- tests/Unit/Types/ObjectSchemaTest.php | 38 +++-- tests/Unit/Types/TypelessSchemaTest.php | 6 +- tests/Unit/Types/UnevaluatedItemsTest.php | 7 +- .../Unit/Types/UnevaluatedPropertiesTest.php | 7 +- tests/Unit/VersionSupportTest.php | 4 +- 20 files changed, 356 insertions(+), 242 deletions(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 9ccafbd..c6ff3f2 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -6,5 +6,6 @@ parameters: level: 10 paths: - src + - tests tmpDir: .phpstan-cache checkBenevolentUnionTypes: true diff --git a/rector.php b/rector.php index ca471f4..8f33a8e 100644 --- a/rector.php +++ b/rector.php @@ -15,7 +15,9 @@ importDocBlockNames: false, removeUnusedImports: true, ) - ->withPhpSets() + ->withPhpSets( + php84: true, + ) ->withSets([ PestSetList::CODING_STYLE, ]) diff --git a/src/Converters/EnumConverter.php b/src/Converters/EnumConverter.php index 0c75d99..e0ce4b4 100644 --- a/src/Converters/EnumConverter.php +++ b/src/Converters/EnumConverter.php @@ -15,12 +15,12 @@ class EnumConverter implements Converter { /** - * @var \ReflectionEnum<\BackedEnum> + * @var \ReflectionEnum<\UnitEnum> */ protected ReflectionEnum $reflection; /** - * @param class-string<\BackedEnum> $enum + * @param class-string<\UnitEnum> $enum */ public function __construct( protected string $enum, diff --git a/tests/Support/SchemaRoundTrip.php b/tests/Support/SchemaRoundTrip.php index 3b1745c..00d1ce5 100644 --- a/tests/Support/SchemaRoundTrip.php +++ b/tests/Support/SchemaRoundTrip.php @@ -18,8 +18,8 @@ final class SchemaRoundTrip /** * Assert every keyword from the source schema is captured in the converted output. * - * @param array $source - * @param array $output + * @param array $source + * @param array $output */ public static function assertSourceSubset(array $source, array $output, string $path = 'root'): void { diff --git a/tests/Unit/ConditionalSchemaTest.php b/tests/Unit/ConditionalSchemaTest.php index d127491..b6bd2a9 100644 --- a/tests/Unit/ConditionalSchemaTest.php +++ b/tests/Unit/ConditionalSchemaTest.php @@ -5,6 +5,7 @@ namespace Cortex\JsonSchema\Tests\Unit; use ArrayObject; +use Pest\Expectation; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaFormat; use Cortex\JsonSchema\Types\ObjectSchema; @@ -186,8 +187,14 @@ ->and($schemaArray['anyOf']) ->toHaveCount(2) ->sequence( - fn($e) => $e->toHaveKey('required', ['credit_card']), - fn($e) => $e->toHaveKey('required', ['bank_transfer']), + fn(Expectation $expectation): Expectation => $expectation->toHaveKey( + 'required', + ['credit_card'], + ), + fn(Expectation $expectation): Expectation => $expectation->toHaveKey( + 'required', + ['bank_transfer'], + ), ); // Validation tests @@ -228,7 +235,10 @@ expect($schemaArray)->toHaveKey('oneOf') ->and($schemaArray['oneOf']) ->toHaveCount(2) - ->sequence(fn($e) => $e->toHaveKey('required', ['email']), fn($e) => $e->toHaveKey('required', ['phone'])); + ->sequence( + fn(Expectation $expectation): Expectation => $expectation->toHaveKey('required', ['email']), + fn(Expectation $expectation): Expectation => $expectation->toHaveKey('required', ['phone']), + ); // Validation tests // Only email is valid diff --git a/tests/Unit/Converters/ClassConverterTest.php b/tests/Unit/Converters/ClassConverterTest.php index 762bb8e..56e78a6 100644 --- a/tests/Unit/Converters/ClassConverterTest.php +++ b/tests/Unit/Converters/ClassConverterTest.php @@ -20,8 +20,7 @@ public float $height = 1.7; })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -65,8 +64,7 @@ public float $height = 1.7; })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -109,8 +107,7 @@ public function __construct( $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -144,8 +141,7 @@ enum UserStatus: string public UserStatus $status = UserStatus::Pending; })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -190,8 +186,7 @@ enum UserStatus: string $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -251,8 +246,7 @@ enum UserStatus: string $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -310,8 +304,7 @@ enum UserStatus: string $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -347,8 +340,7 @@ enum UserStatus: string $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -397,15 +389,17 @@ class CompleteUserInheritanceTest extends BaseEntityInheritanceTest $objectSchema = new ClassConverter(CompleteUserInheritanceTest::class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); + /** @var array> $properties */ + $properties = $array['properties']; + // Should include properties from both base class and child class - expect($array['properties'])->toHaveKey('id'); - expect($array['properties'])->toHaveKeys(['created_at', 'name', 'email']) - ->and($array['properties']['id']['description']) + expect($properties)->toHaveKey('id'); + expect($properties)->toHaveKeys(['created_at', 'name', 'email']) + ->and($properties['id']['description']) ->toBe('Unique identifier') - ->and($array['properties']['created_at']['description']) + ->and($properties['created_at']['description']) ->toBe('Creation timestamp'); }); @@ -435,13 +429,15 @@ class UserWithTraitTest $objectSchema = new ClassConverter(UserWithTraitTest::class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); + /** @var array> $properties */ + $properties = $array['properties']; + // Should include properties from trait and class - expect($array['properties'])->toHaveKey('deleted_at'); - expect($array['properties'])->toHaveKeys(['name', 'email']) - ->and($array['properties']['deleted_at']) + expect($properties)->toHaveKey('deleted_at'); + expect($properties)->toHaveKeys(['name', 'email']) + ->and($properties['deleted_at']) ->toMatchArray([ 'description' => 'Deletion timestamp (soft delete)', 'default' => null, @@ -481,7 +477,6 @@ class CompleteUserCombinedTest extends BaseEntityCombinedTest $objectSchema = new ClassConverter(CompleteUserCombinedTest::class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); $array = $objectSchema->toArray(); // Should include properties from base class, trait, and child class @@ -498,8 +493,6 @@ class CompleteUserCombinedTest extends BaseEntityCombinedTest public static int $count = 0; })->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - $array = $objectSchema->toArray(); expect($array['properties'])->toHaveKey('name')->not->toHaveKey('table')->not->toHaveKey('count'); @@ -522,8 +515,7 @@ public function __construct( $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -562,7 +554,10 @@ public function __construct( $array = $objectSchema->toArray(); - expect($array['properties']['name']['description'])->toBe('The canonical name description'); + /** @var array> $properties */ + $properties = $array['properties']; + + expect($properties['name']['description'])->toBe('The canonical name description'); }); it('throws for unknown property types by default', function (): void { @@ -583,8 +578,6 @@ public function __construct( $objectSchema = new ClassConverter($class, ignoreUnknownTypes: true)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class); - $array = $objectSchema->toArray(); expect($array['properties'])->toHaveKey('name')->not->toHaveKey('createdAt'); @@ -629,7 +622,10 @@ public function __construct( $objectSchema = new ClassConverter($class)->convert(); - expect($objectSchema->toArray()['properties']['ids'])->toBe([ + /** @var array> $properties */ + $properties = $objectSchema->toArray()['properties']; + + expect($properties['ids'])->toBe([ 'type' => 'array', 'description' => 'The user identifiers', 'items' => [ @@ -647,7 +643,10 @@ public function __construct( public ?array $tags = null; })->convert(); - expect($objectSchema->toArray()['properties']['tags'])->toBe([ + /** @var array> $properties */ + $properties = $objectSchema->toArray()['properties']; + + expect($properties['tags'])->toBe([ 'type' => [ 'array', 'null', @@ -669,7 +668,10 @@ public function __construct( public array $dates; })->convert(); - expect($objectSchema->toArray()['properties']['dates'])->toBe([ + /** @var array> $properties */ + $properties = $objectSchema->toArray()['properties']; + + expect($properties['dates'])->toBe([ 'type' => 'array', 'description' => 'Scheduled dates', ]); diff --git a/tests/Unit/Converters/ClosureConverterTest.php b/tests/Unit/Converters/ClosureConverterTest.php index 7657682..68faed8 100644 --- a/tests/Unit/Converters/ClosureConverterTest.php +++ b/tests/Unit/Converters/ClosureConverterTest.php @@ -5,7 +5,6 @@ namespace Cortex\JsonSchema\Tests\Unit\Converters; use Deprecated; -use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Exceptions\SchemaException; use Cortex\JsonSchema\Converters\ClosureConverter; use Cortex\JsonSchema\Exceptions\UnknownTypeException; @@ -16,8 +15,7 @@ $closure = function (string $name, array $fooArray, ?int $age = null): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -53,8 +51,7 @@ enum MyEnum: string $closure = function (MyEnum $myEnum, bool $foo = true): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -94,8 +91,7 @@ enum Status: int $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -143,8 +139,7 @@ public function __construct( $closure = function (UnknownType $unknownType): void {}; $objectSchema = new ClosureConverter($closure, ignoreUnknownTypes: true)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -162,8 +157,7 @@ public function __construct( $closure = function (string $name, CustomClass $customClass, int $age): void {}; $objectSchema = new ClosureConverter($closure, ignoreUnknownTypes: true)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -198,8 +192,7 @@ public function __construct( $closure = function (int|string $foo): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -221,8 +214,7 @@ public function __construct( $closure = function (int|string|null $foo): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -245,8 +237,7 @@ public function __construct( $closure = function (array $items, array $tags = ['default']): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -269,8 +260,7 @@ public function __construct( $closure = function (mixed $data): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -297,8 +287,7 @@ public function __construct( $closure = function (object $data): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -317,8 +306,7 @@ public function __construct( $closure = function (float $amount = 0.0): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -342,8 +330,7 @@ public function __construct( $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -381,8 +368,7 @@ public function __construct( $closure = function (array $items = ['default']): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -407,8 +393,7 @@ public function __construct( $closure = function (string $name, int $age): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -441,8 +426,7 @@ public function __construct( $closure = #[Deprecated('Use processUserDataV2() instead since v2.0')] function (string $name, int $age): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -474,8 +458,7 @@ public function __construct( $closure = function (string $data): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -499,7 +482,10 @@ public function __construct( $closure = function (array $tags): void {}; $objectSchema = new ClosureConverter($closure)->convert(); - expect($objectSchema->toArray()['properties']['tags'])->toBe([ + /** @var array> $properties */ + $properties = $objectSchema->toArray()['properties']; + + expect($properties['tags'])->toBe([ 'type' => 'array', 'description' => "The user's tags", 'items' => [ diff --git a/tests/Unit/Converters/JsonConverterFixturesTest.php b/tests/Unit/Converters/JsonConverterFixturesTest.php index fc96dbb..056e9f1 100644 --- a/tests/Unit/Converters/JsonConverterFixturesTest.php +++ b/tests/Unit/Converters/JsonConverterFixturesTest.php @@ -4,6 +4,7 @@ namespace Cortex\JsonSchema\Tests\Unit\Converters; +use RuntimeException; use DirectoryIterator; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Tests\Support\SchemaRoundTrip; @@ -49,6 +50,10 @@ function jsonSchemaOrgFixtures(): array expect($sourceJson)->not->toBeFalse(); + if ($sourceJson === false) { + throw new RuntimeException('Unable to read fixture: ' . $fixturePath); + } + /** @var array $source */ $source = json_decode($sourceJson, true, flags: JSON_THROW_ON_ERROR); diff --git a/tests/Unit/Converters/JsonConverterTest.php b/tests/Unit/Converters/JsonConverterTest.php index 4558d1b..4b39413 100644 --- a/tests/Unit/Converters/JsonConverterTest.php +++ b/tests/Unit/Converters/JsonConverterTest.php @@ -70,15 +70,19 @@ expect($jsonSchema)->toBeInstanceOf(ObjectSchema::class); $output = $jsonSchema->toArray(); + + /** @var array> $properties */ + $properties = $output['properties']; + expect($output) ->toMatchArray([ 'type' => 'object', 'title' => 'User', 'additionalProperties' => false, ]) - ->and($output['properties']['name']['type']) + ->and($properties['name']['type']) ->toBe('string') - ->and($output['properties']['age']['type']) + ->and($properties['age']['type']) ->toBe('integer') ->and($output['required']) ->toBe(['name']); @@ -102,12 +106,16 @@ expect($jsonSchema)->toBeInstanceOf(ArraySchema::class); $output = $jsonSchema->toArray(); + + /** @var array $items */ + $items = $output['items']; + expect($output) ->toMatchArray([ 'type' => 'array', 'title' => 'String Array', ]) - ->and($output['items']['type']) + ->and($items['type']) ->toBe('string') ->and($output) ->toMatchArray([ @@ -298,10 +306,20 @@ expect($jsonSchema)->toBeInstanceOf(ObjectSchema::class); $output = $jsonSchema->toArray(); - expect($output['properties']['address']['type'])->toBe('object') - ->and($output['properties']['address']['properties']['street']['type']) + + /** @var array> $properties */ + $properties = $output['properties']; + + /** @var array $address */ + $address = $properties['address']; + + /** @var array> $addressProperties */ + $addressProperties = $address['properties']; + + expect($address['type'])->toBe('object') + ->and($addressProperties['street']['type']) ->toBe('string') - ->and($output['properties']['address']['required']) + ->and($address['required']) ->toBe(['street']); }); @@ -354,8 +372,12 @@ expect($jsonSchema)->toBeInstanceOf(ArraySchema::class); $output = $jsonSchema->toArray(); + + /** @var array $contains */ + $contains = $output['contains']; + expect($output['type'])->toBe('array') - ->and($output['contains']['type']) + ->and($contains['type']) ->toBe('string') ->and($output) ->toMatchArray([ @@ -406,14 +428,21 @@ expect($jsonSchema)->toBeInstanceOf(StringSchema::class); $output = $jsonSchema->toArray(); + + /** @var array $contentSchema */ + $contentSchema = $output['contentSchema']; + + /** @var array> $contentSchemaProperties */ + $contentSchemaProperties = $contentSchema['properties']; + expect($output) ->toMatchArray([ 'contentEncoding' => 'base64', 'contentMediaType' => 'application/json', ]) - ->and($output['contentSchema']['type']) + ->and($contentSchema['type']) ->toBe('object') - ->and($output['contentSchema']['properties']['name']['type']) + ->and($contentSchemaProperties['name']['type']) ->toBe('string'); }); @@ -469,6 +498,10 @@ expect($jsonSchema)->toBeInstanceOf(ObjectSchema::class); $output = $jsonSchema->toArray(includeSchemaRef: false); + + /** @var array $not */ + $not = $output['not']; + expect($output)->toHaveKeys(['if', 'then', 'else']) ->and($output['allOf']) ->toHaveCount(1) @@ -476,7 +509,7 @@ ->toHaveCount(1) ->and($output['oneOf']) ->toHaveCount(1) - ->and($output['not']['type']) + ->and($not['type']) ->toBe('null'); }); @@ -506,7 +539,11 @@ $jsonSchema = $converter->convert(); $output = $jsonSchema->toArray(includeSchemaRef: false); - expect($output['$defs']['name']['type'])->toBe('string'); + + /** @var array> $defs */ + $defs = $output['$defs']; + + expect($defs['name']['type'])->toBe('string'); }); it('can handle metadata keywords', function (): void { @@ -551,8 +588,15 @@ $jsonSchema = $converter->convert(); $output = $jsonSchema->toArray(includeSchemaRef: false); - expect($output['patternProperties']['^S_']['type'])->toBe('string') - ->and($output['propertyNames']['pattern']) + + /** @var array> $patternProperties */ + $patternProperties = $output['patternProperties']; + + /** @var array $propertyNames */ + $propertyNames = $output['propertyNames']; + + expect($patternProperties['^S_']['type'])->toBe('string') + ->and($propertyNames['pattern']) ->toBe('^[A-Za-z_]*$') ->and($output) ->toMatchArray([ @@ -578,10 +622,14 @@ $jsonSchema = $converter->convert(); $output = $jsonSchema->toArray(includeSchemaRef: false); + + /** @var array> $dependentSchemas */ + $dependentSchemas = $output['dependentSchemas']; + expect($output['dependentRequired'])->toBe([ 'foo' => ['bar'], ]) - ->and($output['dependentSchemas']['foo']['required']) + ->and($dependentSchemas['foo']['required']) ->toBe(['bar']); }); @@ -616,10 +664,14 @@ $jsonSchema = $converter->convert(); $output = $jsonSchema->toArray(includeSchemaRef: false); - expect($output['prefixItems'])->toHaveCount(2) - ->and($output['prefixItems'][0]['type']) + + /** @var list> $prefixItems */ + $prefixItems = $output['prefixItems']; + + expect($prefixItems)->toHaveCount(2) + ->and($prefixItems[0]['type']) ->toBe('string') - ->and($output['prefixItems'][1]['type']) + ->and($prefixItems[1]['type']) ->toBe('integer') ->and($output['unevaluatedItems']) ->toBeFalse(); @@ -643,10 +695,14 @@ $jsonSchema = $converter->convert(); $output = $jsonSchema->toArray(includeSchemaRef: false); - expect($output['items'])->toHaveCount(2) - ->and($output['items'][0]['type']) + + /** @var list> $items */ + $items = $output['items']; + + expect($items)->toHaveCount(2) + ->and($items[0]['type']) ->toBe('string') - ->and($output['items'][1]['type']) + ->and($items[1]['type']) ->toBe('integer') ->and($output['additionalItems']) ->toBeFalse(); @@ -684,8 +740,12 @@ expect($jsonSchema)->toBeInstanceOf(TypelessSchema::class); $output = $jsonSchema->toArray(includeSchemaRef: false); + + /** @var array> $defs */ + $defs = $output['$defs']; + expect($output)->not->toHaveKey('type') - ->and($output['$defs']['product']['type']) + ->and($defs['product']['type']) ->toBe('object'); }); diff --git a/tests/Unit/DefinitionsSchemaTest.php b/tests/Unit/DefinitionsSchemaTest.php index e4717d2..3d743bd 100644 --- a/tests/Unit/DefinitionsSchemaTest.php +++ b/tests/Unit/DefinitionsSchemaTest.php @@ -22,8 +22,11 @@ $schemaArray = $objectSchema->toArray(); + /** @var array> $defs */ + $defs = $schemaArray['$defs']; + expect($schemaArray)->toHaveKey('$defs.address') - ->and($schemaArray['$defs']['address']) + ->and($defs['address']) ->toHaveKey('type', 'object') ->toHaveKey('required', ['street', 'city', 'country']); }); @@ -47,10 +50,13 @@ $schemaArray = $objectSchema->toArray(); + /** @var array> $defs */ + $defs = $schemaArray['$defs']; + expect($schemaArray)->toHaveKeys(['$defs.address', '$defs.contact']) - ->and($schemaArray['$defs']['address']) + ->and($defs['address']) ->toHaveKey('required', ['street', 'city']) - ->and($schemaArray['$defs']['contact']) + ->and($defs['contact']) ->toHaveKey('required', ['email']); }); diff --git a/tests/Unit/Enums/SchemaTypeTest.php b/tests/Unit/Enums/SchemaTypeTest.php index 6d1431b..3c89317 100644 --- a/tests/Unit/Enums/SchemaTypeTest.php +++ b/tests/Unit/Enums/SchemaTypeTest.php @@ -53,7 +53,7 @@ }); it('can create schema instance', function (SchemaType $schemaType, string $expectedClass): void { - expect($schemaType->instance())->toBeInstanceOf($expectedClass); + expect($schemaType->instance()::class)->toBe($expectedClass); })->with([ 'string schema' => [SchemaType::String, StringSchema::class], 'number schema' => [SchemaType::Number, NumberSchema::class], diff --git a/tests/Unit/SchemaTest.php b/tests/Unit/SchemaTest.php index d7b201a..48b1242 100644 --- a/tests/Unit/SchemaTest.php +++ b/tests/Unit/SchemaTest.php @@ -7,15 +7,10 @@ use stdClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaType; -use Cortex\JsonSchema\Types\NullSchema; -use Cortex\JsonSchema\Types\ArraySchema; -use Cortex\JsonSchema\Types\UnionSchema; -use Cortex\JsonSchema\Types\NumberSchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Types\StringSchema; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Types\BooleanSchema; -use Cortex\JsonSchema\Types\IntegerSchema; use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -23,31 +18,31 @@ it('can create different schema types', function (): void { // Test array schema creation - expect(Schema::array('items'))->toBeInstanceOf(ArraySchema::class); + expect(Schema::array('items')->getTitle())->toBe('items'); // Test boolean schema creation - expect(Schema::boolean('active'))->toBeInstanceOf(BooleanSchema::class); + expect(Schema::boolean('active')->getTitle())->toBe('active'); // Test integer schema creation - expect(Schema::integer('count'))->toBeInstanceOf(IntegerSchema::class); + expect(Schema::integer('count')->getTitle())->toBe('count'); // Test null schema creation - expect(Schema::null('deleted_at'))->toBeInstanceOf(NullSchema::class); + expect(Schema::null('deleted_at')->getTitle())->toBe('deleted_at'); // Test number schema creation - expect(Schema::number('price'))->toBeInstanceOf(NumberSchema::class); + expect(Schema::number('price')->getTitle())->toBe('price'); // Test object schema creation - expect(Schema::object('user'))->toBeInstanceOf(ObjectSchema::class); + expect(Schema::object('user')->getTitle())->toBe('user'); // Test string schema creation - expect(Schema::string('name'))->toBeInstanceOf(StringSchema::class); + expect(Schema::string('name')->getTitle())->toBe('name'); // Test union schema creation - expect(Schema::union([SchemaType::String, SchemaType::Integer]))->toBeInstanceOf(UnionSchema::class); + expect(Schema::union([SchemaType::String, SchemaType::Integer])->toArray()['type'])->toBe(['string', 'integer']); // Test mixed schema creation - expect(Schema::mixed())->toBeInstanceOf(UnionSchema::class); + expect(Schema::mixed()->toArray()['type'])->toHaveCount(7); }); it('can create schemas with default metadata', function (): void { @@ -69,8 +64,7 @@ $closure = function (string $name, array $fooArray, ?int $age = null): void {}; $objectSchema = Schema::fromClosure($closure); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', @@ -113,8 +107,7 @@ public function __construct( $objectSchema = Schema::fromClass($class, publicOnly: true); - expect($objectSchema)->toBeInstanceOf(ObjectSchema::class) - ->and($objectSchema->toArray()) + expect($objectSchema->toArray()) ->toBe([ 'type' => 'object', '$schema' => 'https://json-schema.org/draft/2020-12/schema', diff --git a/tests/Unit/Support/DocParserTest.php b/tests/Unit/Support/DocParserTest.php index ae411c4..f62b9f6 100644 --- a/tests/Unit/Support/DocParserTest.php +++ b/tests/Unit/Support/DocParserTest.php @@ -5,13 +5,23 @@ namespace Cortex\JsonSchema\Tests\Unit\Support; use ReflectionClass; +use RuntimeException; use Cortex\JsonSchema\Support\NodeData; use Cortex\JsonSchema\Support\DocParser; -use Cortex\JsonSchema\Support\NodeCollection; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; covers(DocParser::class); +function requireNodeData(?NodeData $nodeData): NodeData +{ + if (! $nodeData instanceof NodeData) { + throw new RuntimeException('Expected a NodeData instance.'); + } + + return $nodeData; +} + it('can parse description', function (): void { $docblock = '/** This is a test docblock */'; $parser = new DocParser($docblock); @@ -43,46 +53,41 @@ $params = $parser->params(); - expect($params)->toBeInstanceOf(NodeCollection::class) - ->and($params->get('nickname')) - ->toBeInstanceOf(NodeData::class) - ->and($params->get('nickname')->name) + $nodeData = requireNodeData($params->get('nickname')); + $age = requireNodeData($params->get('age')); + $price = requireNodeData($params->get('price')); + $test = requireNodeData($params->get('test')); + $any = requireNodeData($params->get('any')); + + expect($nodeData->name) ->toBe('nickname') - ->and($params->get('nickname')->description) + ->and($nodeData->description) ->toBe('The nickname of the user') - ->and($params->get('nickname')->types) + ->and($nodeData->types) ->toBe(['string', 'null']) - ->and($params->get('age')) - ->toBeInstanceOf(NodeData::class) - ->and($params->get('age')->name) + ->and($age->name) ->toBe('age') - ->and($params->get('age')->description) + ->and($age->description) ->toBe('The age of the user') - ->and($params->get('age')->types) + ->and($age->types) ->toBe(['int', 'null']) - ->and($params->get('price')) - ->toBeInstanceOf(NodeData::class) - ->and($params->get('price')->name) + ->and($price->name) ->toBe('price') - ->and($params->get('price')->description) + ->and($price->description) ->toBe('The price of the product') - ->and($params->get('price')->types) + ->and($price->types) ->toBe(['float', 'int']) - ->and($params->get('test')) - ->toBeInstanceOf(NodeData::class) - ->and($params->get('test')->name) + ->and($test->name) ->toBe('test') - ->and($params->get('test')->description) + ->and($test->description) ->toBe('The test') - ->and($params->get('test')->types) + ->and($test->types) ->toBe(['\Cortex\JsonSchema\Tests\Unit\Support\DocParserTest']) - ->and($params->get('any')) - ->toBeInstanceOf(NodeData::class) - ->and($params->get('any')->name) + ->and($any->name) ->toBe('any') - ->and($params->get('any')->description) + ->and($any->description) ->toBeNull() - ->and($params->get('any')->types) + ->and($any->types) ->toBeEmpty(); }); @@ -90,14 +95,13 @@ $docblock = '/** @var string $nickname The nickname of the user */'; $parser = new DocParser($docblock); - $variable = $parser->variable(); + $nodeData = requireNodeData($parser->variable()); - expect($variable)->toBeInstanceOf(NodeData::class) - ->and($variable->name) + expect($nodeData->name) ->toBe('nickname') - ->and($variable->types) + ->and($nodeData->types) ->toBe(['string']) - ->and($variable->description) + ->and($nodeData->description) ->toBe('The nickname of the user'); }); @@ -105,14 +109,13 @@ $docblock = '/** @var string|int $nickname The nickname of the user */'; $parser = new DocParser($docblock); - $variable = $parser->variable(); + $nodeData = requireNodeData($parser->variable()); - expect($variable)->toBeInstanceOf(NodeData::class) - ->and($variable->name) + expect($nodeData->name) ->toBe('nickname') - ->and($variable->types) + ->and($nodeData->types) ->toBe(['string', 'int']) - ->and($variable->description) + ->and($nodeData->description) ->toBe('The nickname of the user'); }); @@ -132,20 +135,19 @@ $docblock = '/** @var string $name */'; $parser = new DocParser($docblock); - $variable = $parser->variable(); + $nodeData = requireNodeData($parser->variable()); - expect($variable)->toBeInstanceOf(NodeData::class) - ->and($variable->name) + expect($nodeData->name) ->toBe('name') - ->and($variable->types) + ->and($nodeData->types) ->toBe(['string']) - ->and($variable->description) + ->and($nodeData->description) ->toBeNull(); // Empty description should be null // Compare with non-empty description $docblockWithDescription = '/** @var string $name The user name */'; $parserWithDescription = new DocParser($docblockWithDescription); - $variableWithDescription = $parserWithDescription->variable(); + $variableWithDescription = requireNodeData($parserWithDescription->variable()); expect($variableWithDescription->description)->toBe('The user name'); }); @@ -156,14 +158,13 @@ $parser = new DocParser($docblock); $params = $parser->params(); - $nameParam = $params->get('name'); + $nodeData = requireNodeData($params->get('name')); - expect($nameParam)->toBeInstanceOf(NodeData::class) - ->and($nameParam->name) + expect($nodeData->name) ->toBe('name') - ->and($nameParam->types) + ->and($nodeData->types) ->toBe(['string']) - ->and($nameParam->description) + ->and($nodeData->description) ->toBeNull(); // Empty description should be null }); @@ -173,20 +174,19 @@ $parser = new DocParser($docblock); $params = $parser->params(); - $typelessParam = $params->get('typeless'); + $nodeData = requireNodeData($params->get('typeless')); - expect($typelessParam)->toBeInstanceOf(NodeData::class) - ->and($typelessParam->name) + expect($nodeData->name) ->toBe('typeless') - ->and($typelessParam->types) + ->and($nodeData->types) ->toBeEmpty(); // Should be empty array for typeless - expect($typelessParam->description)->toBeNull(); + expect($nodeData->description)->toBeNull(); // Test UnionTypeNode $docblockUnion = '/** @param string|int|float $union */'; $parserUnion = new DocParser($docblockUnion); $nodeCollection = $parserUnion->params(); - $unionParam = $nodeCollection->get('union'); + $unionParam = requireNodeData($nodeCollection->get('union')); expect($unionParam->types)->toBe(['string', 'int', 'float']); @@ -194,7 +194,7 @@ $docblockNullable = '/** @param ?string $nullable */'; $parserNullable = new DocParser($docblockNullable); $paramsNullable = $parserNullable->params(); - $nullableParam = $paramsNullable->get('nullable'); + $nullableParam = requireNodeData($paramsNullable->get('nullable')); expect($nullableParam->types)->toBe(['string', 'null']); @@ -202,7 +202,7 @@ $docblockSimple = '/** @param int $simple */'; $parserSimple = new DocParser($docblockSimple); $paramsSimple = $parserSimple->params(); - $simpleParam = $paramsSimple->get('simple'); + $simpleParam = requireNodeData($paramsSimple->get('simple')); expect($simpleParam->types)->toBe(['int']); }); @@ -224,6 +224,7 @@ $getTextNodesMethod = $reflection->getMethod('getTextNodes'); + /** @var PhpDocNode $phpDocNode */ $phpDocNode = $reflectionMethod->invoke($parser); $textNodes = $getTextNodesMethod->invoke($parser, $phpDocNode->children); @@ -254,22 +255,25 @@ // Test parameter parsing $params = $parser->params(); - expect($params->get('name')->types)->toBe(['string', 'null']) - ->and($params->get('name')->description) + $nodeData = requireNodeData($params->get('name')); + $ageParam = requireNodeData($params->get('age')); + $typelessParam = requireNodeData($params->get('typeless')); + + expect($nodeData->types)->toBe(['string', 'null']) + ->and($nodeData->description) ->toBe('The name parameter') - ->and($params->get('age')->types) + ->and($ageParam->types) ->toBe(['int', 'null']) - ->and($params->get('age')->description) + ->and($ageParam->description) ->toBe('The age parameter') - ->and($params->get('typeless')->types) + ->and($typelessParam->types) ->toBeEmpty() - ->and($params->get('typeless')->description) + ->and($typelessParam->description) ->toBe('A parameter without type'); // Test variable parsing - $variable = $parser->variable(); - expect($variable)->toBeInstanceOf(NodeData::class) - ->and($variable->name) + $variable = requireNodeData($parser->variable()); + expect($variable->name) ->toBe('data') ->and($variable->types) ->toBe(['array']) @@ -303,17 +307,17 @@ $complexUnionDocblock = '/** @param string|int|float|bool|null $complex Multi-type parameter */'; $parser = new DocParser($complexUnionDocblock); $params = $parser->params(); - $complexParam = $params->get('complex'); + $nodeData = requireNodeData($params->get('complex')); - expect($complexParam->types)->toBe(['string', 'int', 'float', 'bool', 'null']) - ->and($complexParam->description) + expect($nodeData->types)->toBe(['string', 'int', 'float', 'bool', 'null']) + ->and($nodeData->description) ->toBe('Multi-type parameter'); // Test deeply nested nullable type $nestedDocblock = '/** @param ?\Cortex\JsonSchema\Support\NodeData $nested */'; $nestedParser = new DocParser($nestedDocblock); $nodeCollection = $nestedParser->params(); - $nestedParam = $nodeCollection->get('nested'); + $nestedParam = requireNodeData($nodeCollection->get('nested')); expect($nestedParam->types)->toBe([sprintf('\%s', NodeData::class), 'null']); @@ -321,14 +325,14 @@ $arrayDocblock = '/** @param array $arrayType */'; $arrayParser = new DocParser($arrayDocblock); $arrayParams = $arrayParser->params(); - $arrayParam = $arrayParams->get('arrayType'); + $arrayParam = requireNodeData($arrayParams->get('arrayType')); expect($arrayParam->types)->toBe(['array']); // Test variable with complex types to exercise the same code path $varDocblock = '/** @var \DateTime|\DateTimeImmutable $dateVar Date variable */'; $varParser = new DocParser($varDocblock); - $variable = $varParser->variable(); + $variable = requireNodeData($varParser->variable()); expect($variable->types)->toBe(['\DateTime', '\DateTimeImmutable']) ->and($variable->description) @@ -352,6 +356,7 @@ $reflection = new ReflectionClass($parser); $reflectionMethod = $reflection->getMethod('parse'); + /** @var PhpDocNode $phpDocNode */ $phpDocNode = $reflectionMethod->invoke($parser); $paramTags = $phpDocNode->getParamTagValues(); $typelessTags = $phpDocNode->getTypelessParamTagValues(); @@ -490,7 +495,11 @@ function (string $docblock, array $expectedItemTypes, string $source): void { ->toBe('A complex method that does many things'); $params = $parser->params(); - expect($params->get('name')->types)->toBe(['string', 'null']) - ->and($params->get('age')->types) + + $nodeData = requireNodeData($params->get('name')); + $ageParam = requireNodeData($params->get('age')); + + expect($nodeData->types)->toBe(['string', 'null']) + ->and($ageParam->types) ->toBe(['int']); }); diff --git a/tests/Unit/Types/ArraySchemaTest.php b/tests/Unit/Types/ArraySchemaTest.php index 66f6e62..9dfe4c6 100644 --- a/tests/Unit/Types/ArraySchemaTest.php +++ b/tests/Unit/Types/ArraySchemaTest.php @@ -5,6 +5,8 @@ namespace Cortex\JsonSchema\Tests\Unit\Types; use ReflectionClass; +use Pest\Expectation; +use RuntimeException; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Enums\SchemaFeature; @@ -266,8 +268,9 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getArrayFeatures'); + /** @var SchemaFeature[] $arrayFeatures */ $arrayFeatures = $reflectionMethod->invoke($arraySchema); - $featureValues = array_map(fn($feature) => $feature->value, $arrayFeatures); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $arrayFeatures); // Should contain all array-specific features expect($featureValues)->toContain('minContains'); @@ -294,8 +297,9 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $allFeatures */ $allFeatures = $reflectionMethod->invoke($arraySchema); - $featureValues = array_map(fn($feature) => $feature->value, $allFeatures); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $allFeatures); // Should contain array-specific features expect($featureValues)->toContain('minContains'); @@ -308,12 +312,20 @@ // Test that array_merge is working correctly (testing the UnwrapArrayMerge mutation) // Compare with direct call to parent method to ensure merger is happening - $getParentFeaturesMethod = $reflection->getParentClass()->getMethod('getUsedFeatures'); + $parentClass = $reflection->getParentClass(); + if ($parentClass === false) { + throw new RuntimeException('Expected ArraySchema to have a parent class.'); + } + + $getParentFeaturesMethod = $parentClass->getMethod('getUsedFeatures'); + + /** @var SchemaFeature[] $parentFeatures */ $parentFeatures = $getParentFeaturesMethod->invoke($arraySchema); $getArrayFeaturesMethod = $reflection->getMethod('getArrayFeatures'); + /** @var SchemaFeature[] $arrayOnlyFeatures */ $arrayOnlyFeatures = $getArrayFeaturesMethod->invoke($arraySchema); // Total features should be more than just array features or just parent features @@ -330,15 +342,15 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getArrayFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($arraySchema); - // Should return an array (not empty as per AlwaysReturnEmptyArray mutation) - expect($features)->toBeArray(); + // Should return a non-empty array (not empty as per AlwaysReturnEmptyArray mutation) expect($features) ->toContainOnlyInstancesOf(SchemaFeature::class)->not->toBeEmpty(); // Should contain the expected features - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->toContain('minContains') ->toContain('unevaluatedItems'); }); @@ -371,9 +383,9 @@ ->toBeArray() ->toHaveCount(3) ->sequence( - fn($e) => $e->toHaveKey('type', 'string'), - fn($e) => $e->toHaveKey('type', 'integer'), - fn($e) => $e->toHaveKey('type', 'boolean'), + fn(Expectation $expectation): Expectation => $expectation->toHaveKey('type', 'string'), + fn(Expectation $expectation): Expectation => $expectation->toHaveKey('type', 'integer'), + fn(Expectation $expectation): Expectation => $expectation->toHaveKey('type', 'boolean'), ); // Test basic validation @@ -441,9 +453,10 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($arraySchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->toContain('prefixItems'); }); @@ -455,9 +468,10 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($arraySchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->not->toContain('prefixItems'); }); @@ -470,11 +484,17 @@ $schemaArray = $arraySchema->toArray(); + /** @var list> $prefixItems */ + $prefixItems = $schemaArray['prefixItems']; + // PrefixItems schemas should not include $schema or title - expect($schemaArray['prefixItems'][0])->not->toHaveKey('$schema'); - expect($schemaArray['prefixItems'][0])->not->toHaveKey('title') - ->and($schemaArray['prefixItems']) - ->sequence(fn($e) => $e->toHaveKey('type', 'string'), fn($e) => $e->not->toHaveKey('$schema')) - ->and($schemaArray['prefixItems'][1])->not->toHaveKey('title') + expect($prefixItems[0])->not->toHaveKey('$schema'); + expect($prefixItems[0])->not->toHaveKey('title') + ->and($prefixItems) + ->sequence( + fn(Expectation $expectation): Expectation => $expectation->toHaveKey('type', 'string'), + fn(Expectation $expectation): Expectation => $expectation->not->toHaveKey('$schema'), + ) + ->and($prefixItems[1])->not->toHaveKey('title') ->toHaveKey('type', 'integer'); }); diff --git a/tests/Unit/Types/DependentSchemasTest.php b/tests/Unit/Types/DependentSchemasTest.php index 1ba65b4..5110200 100644 --- a/tests/Unit/Types/DependentSchemasTest.php +++ b/tests/Unit/Types/DependentSchemasTest.php @@ -8,6 +8,7 @@ use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaFormat; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -26,10 +27,13 @@ $schemaArray = $objectSchema->toArray(); + /** @var array $dependentSchemas */ + $dependentSchemas = $schemaArray['dependentSchemas']; + expect($schemaArray)->toHaveKey('dependentSchemas') - ->and($schemaArray['dependentSchemas']) + ->and($dependentSchemas) ->toHaveKey('credit_card') - ->and($schemaArray['dependentSchemas']['credit_card']) + ->and($dependentSchemas['credit_card']) ->toBe([ 'type' => 'object', 'properties' => [ @@ -161,9 +165,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->toContain('dependentSchemas'); }); @@ -177,9 +182,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->not->toContain('dependentSchemas'); }); @@ -306,8 +312,12 @@ // Verify dependent schema structure expect($schemaArray)->toHaveKey('dependentSchemas'); - expect($schemaArray['dependentSchemas'])->toHaveKey('credit_card') - ->and($schemaArray['dependentSchemas']['credit_card']) + + /** @var array $dependentSchemas */ + $dependentSchemas = $schemaArray['dependentSchemas']; + + expect($dependentSchemas)->toHaveKey('credit_card') + ->and($dependentSchemas['credit_card']) ->toBe([ 'type' => 'object', 'properties' => [ diff --git a/tests/Unit/Types/ObjectSchemaTest.php b/tests/Unit/Types/ObjectSchemaTest.php index 428116f..8087621 100644 --- a/tests/Unit/Types/ObjectSchemaTest.php +++ b/tests/Unit/Types/ObjectSchemaTest.php @@ -12,7 +12,6 @@ use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Types\IntegerSchema; -use Opis\JsonSchema\Errors\ValidationError; use Cortex\JsonSchema\Exceptions\SchemaException; covers(ObjectSchema::class); @@ -108,9 +107,7 @@ '/email' => [ "The data must match the 'email' format", ], - ]) - ->and($schemaException->getError()) - ->toBeInstanceOf(ValidationError::class); + ]); throw $schemaException; } @@ -125,10 +122,13 @@ $schemaArray = $objectSchema->toArray(); + /** @var array $properties */ + $properties = $schemaArray['properties']; + expect($schemaArray)->toHaveKey('properties.foo.type', 'string') ->toHaveKey('properties.foo.title', 'Foo') ->toHaveKey('properties.bar.type', 'string') - ->and($schemaArray['properties']['bar'])->not->toHaveKey('title'); + ->and($properties['bar'])->not->toHaveKey('title'); }); it('can create an object schema with additional properties control', function (): void { @@ -345,10 +345,11 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); // Should contain UnevaluatedProperties and DependentSchemas features - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->toContain('unevaluatedProperties') ->toContain('dependentSchemas'); @@ -370,7 +371,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUnevaluatedPropertiesFeatures'); + /** @var SchemaFeature[] $featuresWithUnevaluated */ $featuresWithUnevaluated = $reflectionMethod->invoke($objectSchema); + + /** @var SchemaFeature[] $featuresWithoutUnevaluated */ $featuresWithoutUnevaluated = $reflectionMethod->invoke($objectWithoutUnevaluated); // Object with unevaluated properties should return the feature @@ -394,7 +398,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getDependentSchemasFeatures'); + /** @var SchemaFeature[] $featuresWithDependent */ $featuresWithDependent = $reflectionMethod->invoke($objectSchema); + + /** @var SchemaFeature[] $featuresWithoutDependent */ $featuresWithoutDependent = $reflectionMethod->invoke($objectWithoutDependent); // Object with dependent schemas should return the feature @@ -417,10 +424,11 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); // Get feature values for comparison - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); // Should not contain duplicates expect($featureValues)->toBe(array_unique($featureValues)); @@ -439,10 +447,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); - // Should return an array - expect($features)->toBeArray(); + // Should return an array of SchemaFeature instances expect($features) ->toContainOnlyInstancesOf(SchemaFeature::class); @@ -466,8 +474,9 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); // Should contain parent conditional features expect($featureValues)->toContain('if'); @@ -478,8 +487,9 @@ $simpleObjectSchema = Schema::object('simple') ->properties(Schema::string('name')); + /** @var SchemaFeature[] $simpleFeaturesResult */ $simpleFeaturesResult = $reflectionMethod->invoke($simpleObjectSchema); - $simpleFeatureValues = array_map(fn($feature) => $feature->value, $simpleFeaturesResult); + $simpleFeatureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $simpleFeaturesResult); // Simple object should not have conditional features expect($simpleFeatureValues)->not->toContain('if'); @@ -498,8 +508,7 @@ $properties = $objectSchema->getProperties(); - expect($properties)->toBeArray() - ->and($properties)->toHaveCount(3) + expect($properties)->toHaveCount(3) ->and($properties)->toHaveKey('name') ->and($properties)->toHaveKey('age') ->and($properties)->toHaveKey('email'); @@ -520,8 +529,7 @@ $requiredProperties = $objectSchema->getRequiredProperties(); - expect($requiredProperties)->toBeArray() - ->and($requiredProperties)->toHaveCount(2) + expect($requiredProperties)->toHaveCount(2) ->and($requiredProperties)->toContain('name') ->and($requiredProperties)->toContain('email') ->and($requiredProperties)->not->toContain('age'); diff --git a/tests/Unit/Types/TypelessSchemaTest.php b/tests/Unit/Types/TypelessSchemaTest.php index d80ac4a..b5ca4aa 100644 --- a/tests/Unit/Types/TypelessSchemaTest.php +++ b/tests/Unit/Types/TypelessSchemaTest.php @@ -22,8 +22,6 @@ ), ); - expect($typelessSchema)->toBeInstanceOf(TypelessSchema::class); - $schemaArray = $typelessSchema->toArray(); expect($schemaArray)->not->toHaveKey('type') @@ -39,6 +37,7 @@ Schema::string('city')->required(), )); + /** @var array{'$defs': array} $schemaArray */ $schemaArray = $typelessSchema->toArray(); expect($schemaArray)->not->toHaveKey('type') @@ -49,6 +48,5 @@ it('stays typeless when nullable is called', function (): void { $typelessSchema = Schema::typeless('shape')->nullable(); - expect($typelessSchema)->toBeInstanceOf(TypelessSchema::class) - ->and($typelessSchema->toArray())->not->toHaveKey('type'); + expect($typelessSchema->toArray())->not->toHaveKey('type'); }); diff --git a/tests/Unit/Types/UnevaluatedItemsTest.php b/tests/Unit/Types/UnevaluatedItemsTest.php index a276f54..9273c9b 100644 --- a/tests/Unit/Types/UnevaluatedItemsTest.php +++ b/tests/Unit/Types/UnevaluatedItemsTest.php @@ -7,6 +7,7 @@ use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\ArraySchema; +use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -103,9 +104,10 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var \Cortex\JsonSchema\Enums\SchemaFeature[] $features */ $features = $reflectionMethod->invoke($arraySchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->toContain('unevaluatedItems'); }); @@ -117,9 +119,10 @@ $reflection = new ReflectionClass($arraySchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var \Cortex\JsonSchema\Enums\SchemaFeature[] $features */ $features = $reflectionMethod->invoke($arraySchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->not->toContain('unevaluatedItems'); }); diff --git a/tests/Unit/Types/UnevaluatedPropertiesTest.php b/tests/Unit/Types/UnevaluatedPropertiesTest.php index 24b881c..2ec1dac 100644 --- a/tests/Unit/Types/UnevaluatedPropertiesTest.php +++ b/tests/Unit/Types/UnevaluatedPropertiesTest.php @@ -7,6 +7,7 @@ use ReflectionClass; use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -124,9 +125,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var \Cortex\JsonSchema\Enums\SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->toContain('unevaluatedProperties'); }); @@ -140,9 +142,10 @@ $reflection = new ReflectionClass($objectSchema); $reflectionMethod = $reflection->getMethod('getUsedFeatures'); + /** @var \Cortex\JsonSchema\Enums\SchemaFeature[] $features */ $features = $reflectionMethod->invoke($objectSchema); - $featureValues = array_map(fn($feature) => $feature->value, $features); + $featureValues = array_map(fn(SchemaFeature $schemaFeature) => $schemaFeature->value, $features); expect($featureValues)->not->toContain('unevaluatedProperties'); }); diff --git a/tests/Unit/VersionSupportTest.php b/tests/Unit/VersionSupportTest.php index 535cb7d..a0aff95 100644 --- a/tests/Unit/VersionSupportTest.php +++ b/tests/Unit/VersionSupportTest.php @@ -6,7 +6,6 @@ use Cortex\JsonSchema\Schema; use Cortex\JsonSchema\Enums\SchemaType; -use Cortex\JsonSchema\Types\StringSchema; use Cortex\JsonSchema\Enums\SchemaFeature; use Cortex\JsonSchema\Enums\SchemaVersion; @@ -248,8 +247,7 @@ it('can create schema factory with version parameter', function (): void { $stringSchema = Schema::string('test', SchemaVersion::Draft_2020_12); - expect($stringSchema)->toBeInstanceOf(StringSchema::class) - ->and($stringSchema->getVersion()) + expect($stringSchema->getVersion()) ->toBe(SchemaVersion::Draft_2020_12); }); From 041cdc8290e434df6f091ba4eb1b009adc0d8aa8 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Sun, 2 Aug 2026 23:41:24 +0100 Subject: [PATCH 3/8] dont run mutation for now --- .github/workflows/run-tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index de822d2..6d1bb52 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,10 +38,10 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/php.json" echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: Execute tests with mutation - if: ${{ matrix.os == 'ubuntu-latest' }} - run: vendor/bin/pest --colors=always --mutate --parallel --min=80 + # - name: Execute tests with mutation + # if: ${{ matrix.os == 'ubuntu-latest' }} + # run: vendor/bin/pest --colors=always --mutate --parallel --min=80 - name: Execute tests - if: ${{ matrix.os != 'ubuntu-latest' }} + # if: ${{ matrix.os != 'ubuntu-latest' }} run: vendor/bin/pest --colors=always From 62c05d33f7a0e3728804b72b82f86a5bb5962994 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Sun, 2 Aug 2026 23:44:59 +0100 Subject: [PATCH 4/8] bump minimum --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1bdfdfa..089eda7 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require": { "php": "^8.4", "ext-json": "*", - "opis/json-schema": "^2.4", + "opis/json-schema": "^2.5", "phpstan/phpdoc-parser": "^2.3" }, "require-dev": { From 7a12aff4595fa0c4dd141e4e52ebff251d7424ea Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Sun, 2 Aug 2026 23:56:56 +0100 Subject: [PATCH 5/8] tweak --- composer.json | 2 +- ecs.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 089eda7..865d9b0 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ } }, "scripts": { - "test": "pest --parallel --tia", + "test": "pest --parallel", "ecs": "ecs check --fix", "ecs:check": "ecs check", "rector": "rector process", diff --git a/ecs.php b/ecs.php index 49dd243..f3e2bd1 100644 --- a/ecs.php +++ b/ecs.php @@ -33,7 +33,7 @@ cleanCode: true, ) ->withPhpCsFixerSets( - php83Migration: true, + php84Migration: true, ) ->withRules([ NotOperatorWithSuccessorSpaceFixer::class, From 7eda957e50d5d74f3701fbcfa52439b841cc9488 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Mon, 3 Aug 2026 11:11:00 +0100 Subject: [PATCH 6/8] improve converters --- docs/json-schema/schema-types/object.mdx | 19 + src/Converters/ClassConverter.php | 136 +++--- src/Converters/ClosureConverter.php | 76 +--- .../Concerns/InteractsWithDocblocks.php | 49 +++ .../Concerns/InteractsWithEnums.php | 61 +++ .../Concerns/InteractsWithMembers.php | 30 ++ .../Concerns/InteractsWithTypes.php | 39 +- src/Converters/EnumConverter.php | 39 +- src/Converters/JsonConverter.php | 396 ++++++++---------- src/Support/SchemaData.php | 96 +++++ src/Types/Concerns/HasProperties.php | 30 +- 11 files changed, 570 insertions(+), 401 deletions(-) create mode 100644 src/Converters/Concerns/InteractsWithDocblocks.php create mode 100644 src/Converters/Concerns/InteractsWithEnums.php create mode 100644 src/Converters/Concerns/InteractsWithMembers.php create mode 100644 src/Support/SchemaData.php diff --git a/docs/json-schema/schema-types/object.mdx b/docs/json-schema/schema-types/object.mdx index 92b8e80..92dab79 100644 --- a/docs/json-schema/schema-types/object.mdx +++ b/docs/json-schema/schema-types/object.mdx @@ -103,6 +103,25 @@ $productSchema = Schema::object('product') ``` +## Named Properties + +When the property key should not come from the schema title, use `property()` to set the name explicitly. You can also mark properties as required in bulk with `requireProperties()`: + +```php +$userSchema = Schema::object('user') + ->property('name', Schema::string()->minLength(1), required: true) + ->property('email', Schema::string()->format(SchemaFormat::Email), required: true) + ->property('bio', Schema::string()->maxLength(500)); + +// Or mark required properties separately +$configSchema = Schema::object('config') + ->property('host', Schema::string()) + ->property('port', Schema::integer()) + ->requireProperties('host', 'port'); +``` + +`property()` is especially useful when converting from raw JSON Schema, where property keys are independent of any `title` metadata. + ## Property Titles Property names are defined by the schema constructor argument, while `title()` is metadata and only included when it differs from the property name: diff --git a/src/Converters/ClassConverter.php b/src/Converters/ClassConverter.php index 5a00a31..f779e2b 100644 --- a/src/Converters/ClassConverter.php +++ b/src/Converters/ClassConverter.php @@ -4,14 +4,10 @@ namespace Cortex\JsonSchema\Converters; -use BackedEnum; -use ReflectionEnum; use ReflectionClass; use ReflectionProperty; -use ReflectionNamedType; use ReflectionParameter; use Cortex\JsonSchema\Support\NodeData; -use Cortex\JsonSchema\Support\DocParser; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Contracts\Converter; @@ -19,47 +15,48 @@ use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\JsonSchema\Support\NodeCollection; use Cortex\JsonSchema\Exceptions\UnknownTypeException; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithEnums; use Cortex\JsonSchema\Converters\Concerns\InteractsWithTypes; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithMembers; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithDocblocks; class ClassConverter implements Converter { use InteractsWithTypes; + use InteractsWithEnums; + use InteractsWithMembers; + use InteractsWithDocblocks; /** * @var \ReflectionClass */ protected ReflectionClass $reflection; + protected SchemaVersion $version; + + /** + * @var array|null + */ + private ?array $constructorParameters = null; + /** * @param object|class-string $class */ public function __construct( protected object|string $class, protected bool $publicOnly = true, - protected ?SchemaVersion $version = null, + ?SchemaVersion $schemaVersion = null, protected bool $ignoreUnknownTypes = false, ) { $this->reflection = new ReflectionClass($this->class); - $this->version = $version ?? SchemaVersion::default(); + $this->version = $schemaVersion ?? SchemaVersion::default(); } public function convert(): ObjectSchema { $objectSchema = new ObjectSchema(schemaVersion: $this->version); - $docParser = $this->getDocParser($this->reflection); - - if ($docParser?->isDeprecated() === true) { - $objectSchema->deprecated(); - } - - // Get the description from the doc parser - $description = $docParser?->description() ?? null; - - // Add the description to the schema if it exists - if ($description !== null) { - $objectSchema->description($description); - } + $this->applySchemaDocblock($objectSchema, $this->docParser($this->reflection)); $properties = $this->reflection->getProperties( $this->publicOnly ? ReflectionProperty::IS_PUBLIC : null, @@ -77,7 +74,7 @@ public function convert(): ObjectSchema } try { - $objectSchema->properties(self::getSchemaFromReflectionProperty($property, $promotedParams)); + $objectSchema->properties($this->getSchemaFromReflectionProperty($property, $promotedParams)); } catch (UnknownTypeException $unknownTypeException) { if ($this->ignoreUnknownTypes) { continue; @@ -99,14 +96,9 @@ protected function getSchemaFromReflectionProperty( ReflectionProperty $reflectionProperty, ?NodeCollection $nodeCollection = null, ): JsonSchema { - $type = $reflectionProperty->getType(); - - // @phpstan-ignore argument.type - $jsonSchema = self::getSchemaFromReflectionType($type); + $jsonSchema = $this->baseMemberSchema($reflectionProperty); - $jsonSchema->title($reflectionProperty->getName()); - - $docParser = $this->getDocParser($reflectionProperty); + $docParser = $this->docParser($reflectionProperty); if ($docParser?->isDeprecated() === true) { $jsonSchema->deprecated(); @@ -126,10 +118,6 @@ protected function getSchemaFromReflectionProperty( ); } - if ($type === null || $type->allowsNull()) { - $jsonSchema->nullable(); - } - // Promoted properties report their default value on the constructor // parameter rather than on the property itself. $promotedParameter = $reflectionProperty->isPromoted() @@ -144,31 +132,11 @@ protected function getSchemaFromReflectionProperty( ? $reflectionProperty->getDefaultValue() : $promotedParameter?->getDefaultValue(); - // If the default value is a backed enum, use its value - if ($defaultValue instanceof BackedEnum) { - $defaultValue = $defaultValue->value; - } - - $jsonSchema->default($defaultValue); + $jsonSchema->default($this->unwrapEnumValue($defaultValue)); } else { $jsonSchema->required(); } - // If it's an enum, add the possible values - if ($type instanceof ReflectionNamedType) { - $typeName = $type->getName(); - - if (enum_exists($typeName)) { - $reflectionEnum = new ReflectionEnum($typeName); - - if ($reflectionEnum->isBacked()) { - /** @var non-empty-array $values */ - $values = array_column($typeName::cases(), 'value'); - $jsonSchema->enum($values); - } - } - } - return $jsonSchema; } @@ -186,11 +154,7 @@ protected function resolvePropertyDescription( return $nodeData->description; } - if ($reflectionProperty->isPromoted()) { - return $nodeCollection?->get($reflectionProperty->getName())?->description; - } - - return null; + return $this->promotedNode($reflectionProperty, $nodeCollection)?->description; } /** @@ -211,21 +175,25 @@ protected function resolvePropertyItemTypes( return $itemTypes; } - $promotedNode = $nodeCollection?->get($reflectionProperty->getName()); + $promotedNode = $this->promotedNode($reflectionProperty, $nodeCollection); return $promotedNode instanceof NodeData ? $promotedNode->itemTypes : []; } /** - * @param ReflectionProperty|ReflectionClass $reflection + * Look up the constructor `@param` node for a promoted property. + * + * @param \Cortex\JsonSchema\Support\NodeCollection|null $nodeCollection */ - protected function getDocParser(ReflectionProperty|ReflectionClass $reflection): ?DocParser - { - $docComment = $reflection->getDocComment(); + protected function promotedNode( + ReflectionProperty $reflectionProperty, + ?NodeCollection $nodeCollection, + ): ?NodeData { + if (! $reflectionProperty->isPromoted()) { + return null; + } - return is_string($docComment) - ? new DocParser($docComment) - : null; + return $nodeCollection?->get($reflectionProperty->getName()); } /** @@ -236,11 +204,12 @@ protected function getDocParser(ReflectionProperty|ReflectionClass $reflection): protected function getConstructorParams(): ?NodeCollection { $constructor = $this->reflection->getConstructor(); - $docComment = $constructor?->getDocComment(); - return is_string($docComment) - ? new DocParser($docComment)->params() - : null; + if ($constructor === null) { + return null; + } + + return $this->docParser($constructor)?->params(); } /** @@ -248,14 +217,31 @@ protected function getConstructorParams(): ?NodeCollection */ protected function getConstructorParameter(string $name): ?ReflectionParameter { - $constructor = $this->reflection->getConstructor(); + return $this->getConstructorParameters()[$name] ?? null; + } - foreach ($constructor?->getParameters() ?? [] as $parameter) { - if ($parameter->getName() === $name) { - return $parameter; - } + /** + * Cache constructor parameters keyed by name. + * + * @return array + */ + protected function getConstructorParameters(): array + { + if ($this->constructorParameters !== null) { + return $this->constructorParameters; } - return null; + $parameters = []; + + foreach ($this->reflection->getConstructor()?->getParameters() ?? [] as $parameter) { + $parameters[$parameter->getName()] = $parameter; + } + + return $this->constructorParameters = $parameters; + } + + protected function schemaVersion(): SchemaVersion + { + return $this->version; } } diff --git a/src/Converters/ClosureConverter.php b/src/Converters/ClosureConverter.php index 9f10d4a..46b6ae1 100644 --- a/src/Converters/ClosureConverter.php +++ b/src/Converters/ClosureConverter.php @@ -5,13 +5,9 @@ namespace Cortex\JsonSchema\Converters; use Closure; -use BackedEnum; -use ReflectionEnum; use ReflectionFunction; -use ReflectionNamedType; use ReflectionParameter; use Cortex\JsonSchema\Support\NodeData; -use Cortex\JsonSchema\Support\DocParser; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Contracts\Converter; @@ -19,40 +15,37 @@ use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\JsonSchema\Support\NodeCollection; use Cortex\JsonSchema\Exceptions\UnknownTypeException; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithEnums; use Cortex\JsonSchema\Converters\Concerns\InteractsWithTypes; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithMembers; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithDocblocks; class ClosureConverter implements Converter { use InteractsWithTypes; + use InteractsWithEnums; + use InteractsWithMembers; + use InteractsWithDocblocks; protected ReflectionFunction $reflection; + protected SchemaVersion $version; + public function __construct( protected Closure $closure, - protected ?SchemaVersion $version = null, + ?SchemaVersion $schemaVersion = null, protected bool $ignoreUnknownTypes = false, ) { $this->reflection = new ReflectionFunction($this->closure); - $this->version = $version ?? SchemaVersion::default(); + $this->version = $schemaVersion ?? SchemaVersion::default(); } public function convert(): ObjectSchema { $objectSchema = new ObjectSchema(schemaVersion: $this->version); - $docParser = $this->getDocParser(); - - if ($docParser?->isDeprecated() === true || $this->reflection->isDeprecated()) { - $objectSchema->deprecated(); - } - - // Get the description from the doc parser - $description = $docParser?->description() ?? null; - - // Add the description to the schema if it exists - if ($description !== null) { - $objectSchema->description($description); - } + $docParser = $this->docParser($this->reflection); + $this->applySchemaDocblock($objectSchema, $docParser, $this->reflection->isDeprecated()); // Get the parameters from the doc parser $params = $docParser?->params(); @@ -60,7 +53,7 @@ public function convert(): ObjectSchema // Add the parameters to the objectschema foreach ($this->reflection->getParameters() as $reflectionParameter) { try { - $objectSchema->properties(self::getSchemaFromReflectionParameter($reflectionParameter, $params)); + $objectSchema->properties($this->getSchemaFromReflectionParameter($reflectionParameter, $params)); } catch (UnknownTypeException $e) { // If ignoreUnknownTypes is true, skip this parameter if ($this->ignoreUnknownTypes) { @@ -84,12 +77,7 @@ protected function getSchemaFromReflectionParameter( ReflectionParameter $reflectionParameter, ?NodeCollection $nodeCollection = null, ): JsonSchema { - $type = $reflectionParameter->getType(); - - // @phpstan-ignore argument.type - $jsonSchema = self::getSchemaFromReflectionType($type); - - $jsonSchema->title($reflectionParameter->getName()); + $jsonSchema = $this->baseMemberSchema($reflectionParameter); $docParam = $nodeCollection?->get($reflectionParameter->getName()); @@ -105,49 +93,19 @@ protected function getSchemaFromReflectionParameter( ); } - if ($type === null || $type->allowsNull()) { - $jsonSchema->nullable(); - } - if ($reflectionParameter->isDefaultValueAvailable() && ! $reflectionParameter->isDefaultValueConstant()) { - $defaultValue = $reflectionParameter->getDefaultValue(); - - // If the default value is a backed enum, use its value - if ($defaultValue instanceof BackedEnum) { - $defaultValue = $defaultValue->value; - } - - $jsonSchema->default($defaultValue); + $jsonSchema->default($this->unwrapEnumValue($reflectionParameter->getDefaultValue())); } if (! $reflectionParameter->isOptional()) { $jsonSchema->required(); } - // If it's an enum, add the possible values - if ($type instanceof ReflectionNamedType) { - $typeName = $type->getName(); - - if (enum_exists($typeName)) { - $reflectionEnum = new ReflectionEnum($typeName); - - if ($reflectionEnum->isBacked()) { - /** @var non-empty-array $values */ - $values = array_column($typeName::cases(), 'value'); - $jsonSchema->enum($values); - } - } - } - return $jsonSchema; } - protected function getDocParser(?string $docComment = null): ?DocParser + protected function schemaVersion(): SchemaVersion { - $docComment ??= $this->reflection->getDocComment(); - - return is_string($docComment) - ? new DocParser($docComment) - : null; + return $this->version; } } diff --git a/src/Converters/Concerns/InteractsWithDocblocks.php b/src/Converters/Concerns/InteractsWithDocblocks.php new file mode 100644 index 0000000..52880ac --- /dev/null +++ b/src/Converters/Concerns/InteractsWithDocblocks.php @@ -0,0 +1,49 @@ +|ReflectionEnum<\UnitEnum>|ReflectionProperty|ReflectionFunctionAbstract $reflector + */ + protected function docParser( + ReflectionClass|ReflectionEnum|ReflectionProperty|ReflectionFunctionAbstract $reflector, + ): ?DocParser { + $docComment = $reflector->getDocComment(); + + return is_string($docComment) + ? new DocParser($docComment) + : null; + } + + /** + * Apply deprecated and description metadata from a docblock onto a schema. + */ + protected function applySchemaDocblock( + JsonSchema $jsonSchema, + ?DocParser $docParser, + bool $deprecated = false, + ): void { + if ($deprecated || $docParser?->isDeprecated() === true) { + $jsonSchema->deprecated(); + } + + $description = $docParser?->description(); + + if ($description !== null) { + $jsonSchema->description($description); + } + } +} diff --git a/src/Converters/Concerns/InteractsWithEnums.php b/src/Converters/Concerns/InteractsWithEnums.php new file mode 100644 index 0000000..9e0d698 --- /dev/null +++ b/src/Converters/Concerns/InteractsWithEnums.php @@ -0,0 +1,61 @@ +|null + */ + protected function backedEnumValues(string $className): ?array + { + if (! enum_exists($className)) { + return null; + } + + $reflectionEnum = new ReflectionEnum($className); + + if (! $reflectionEnum->isBacked()) { + return null; + } + + /** @var non-empty-array $values */ + $values = array_column($className::cases(), 'value'); + + return $values; + } + + /** + * Apply backed enum values to a schema when the reflection type is a backed enum. + */ + protected function applyBackedEnumValues(JsonSchema $jsonSchema, ?ReflectionType $reflectionType): void + { + if (! $reflectionType instanceof ReflectionNamedType) { + return; + } + + $values = $this->backedEnumValues($reflectionType->getName()); + + if ($values !== null) { + $jsonSchema->enum($values); + } + } + + /** + * Unwrap a backed enum to its scalar value, leaving other values untouched. + */ + protected function unwrapEnumValue(mixed $value): mixed + { + return $value instanceof BackedEnum ? $value->value : $value; + } +} diff --git a/src/Converters/Concerns/InteractsWithMembers.php b/src/Converters/Concerns/InteractsWithMembers.php new file mode 100644 index 0000000..6f1a68f --- /dev/null +++ b/src/Converters/Concerns/InteractsWithMembers.php @@ -0,0 +1,30 @@ +getType(); + $jsonSchema = $this->getSchemaFromReflectionType($type); + $jsonSchema->title($member->getName()); + + if ($type === null || $type->allowsNull()) { + $jsonSchema->nullable(); + } + + $this->applyBackedEnumValues($jsonSchema, $type); + + return $jsonSchema; + } +} diff --git a/src/Converters/Concerns/InteractsWithTypes.php b/src/Converters/Concerns/InteractsWithTypes.php index f7b9d4b..858e8be 100644 --- a/src/Converters/Concerns/InteractsWithTypes.php +++ b/src/Converters/Concerns/InteractsWithTypes.php @@ -5,39 +5,42 @@ namespace Cortex\JsonSchema\Converters\Concerns; use ReflectionEnum; +use ReflectionType; use ReflectionNamedType; use ReflectionUnionType; use ReflectionIntersectionType; use Cortex\JsonSchema\Enums\SchemaType; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\UnionSchema; +use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\JsonSchema\Exceptions\SchemaException; trait InteractsWithTypes { + abstract protected function schemaVersion(): SchemaVersion; + /** * Resolve the schema instance from the given reflection type. */ - protected function getSchemaFromReflectionType( - ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type, - ): JsonSchema { + protected function getSchemaFromReflectionType(?ReflectionType $type): JsonSchema + { $schemaTypes = match (true) { $type instanceof ReflectionUnionType, $type instanceof ReflectionIntersectionType => array_map( - // @phpstan-ignore argument.type - fn(ReflectionNamedType $reflectionNamedType): SchemaType => $this->resolveSchemaType( - $reflectionNamedType, + fn(ReflectionType $reflectionType): SchemaType => $this->resolveSchemaType( + $this->assertNamedType($reflectionType), ), $type->getTypes(), ), // If the parameter is not typed or explicitly typed as mixed, we use all schema types - in_array($type?->getName(), ['mixed', null], true) => SchemaType::cases(), - default => [$this->resolveSchemaType($type)], + ! $type instanceof ReflectionType || ($type instanceof ReflectionNamedType && $type->getName() === 'mixed') => SchemaType::cases(), + $type instanceof ReflectionNamedType => [$this->resolveSchemaType($type)], + default => throw new SchemaException('Unsupported reflection type: ' . $type::class), }; return count($schemaTypes) === 1 - ? $schemaTypes[0]->instance(null, $this->version) - : new UnionSchema(array_values($schemaTypes), null, $this->version); + ? $schemaTypes[0]->instance(null, $this->schemaVersion()) + : new UnionSchema(array_values($schemaTypes), null, $this->schemaVersion()); } /** @@ -99,7 +102,19 @@ protected function getItemsSchema(array $itemTypes): ?JsonSchema } return count($schemaTypes) === 1 - ? $schemaTypes[0]->instance(null, $this->version) - : new UnionSchema($schemaTypes, null, $this->version); + ? $schemaTypes[0]->instance(null, $this->schemaVersion()) + : new UnionSchema($schemaTypes, null, $this->schemaVersion()); + } + + /** + * Assert that a reflection type from a union/intersection is a named type. + */ + private function assertNamedType(ReflectionType $reflectionType): ReflectionNamedType + { + if (! $reflectionType instanceof ReflectionNamedType) { + throw new SchemaException('Unsupported reflection type in union/intersection: ' . $reflectionType::class); + } + + return $reflectionType; } } diff --git a/src/Converters/EnumConverter.php b/src/Converters/EnumConverter.php index e0ce4b4..b74118e 100644 --- a/src/Converters/EnumConverter.php +++ b/src/Converters/EnumConverter.php @@ -5,29 +5,35 @@ namespace Cortex\JsonSchema\Converters; use ReflectionEnum; -use Cortex\JsonSchema\Support\DocParser; use Cortex\JsonSchema\Types\StringSchema; use Cortex\JsonSchema\Contracts\Converter; use Cortex\JsonSchema\Enums\SchemaVersion; use Cortex\JsonSchema\Types\IntegerSchema; use Cortex\JsonSchema\Exceptions\SchemaException; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithEnums; +use Cortex\JsonSchema\Converters\Concerns\InteractsWithDocblocks; class EnumConverter implements Converter { + use InteractsWithEnums; + use InteractsWithDocblocks; + /** * @var \ReflectionEnum<\UnitEnum> */ protected ReflectionEnum $reflection; + protected SchemaVersion $version; + /** * @param class-string<\UnitEnum> $enum */ public function __construct( protected string $enum, - protected ?SchemaVersion $version = null, + ?SchemaVersion $schemaVersion = null, ) { $this->reflection = new ReflectionEnum($this->enum); - $this->version = $version ?? SchemaVersion::default(); + $this->version = $schemaVersion ?? SchemaVersion::default(); if (! $this->reflection->isBacked()) { throw new SchemaException('Enum must be a backed enum'); @@ -36,8 +42,7 @@ public function __construct( public function convert(): StringSchema|IntegerSchema { - // Get the basename of the enum namespace - $enumName = basename(str_replace('\\', '/', $this->enum)); + $enumName = $this->reflection->getShortName(); // Determine the backing type $schema = match ($this->reflection->getBackingType()?->getName()) { @@ -48,28 +53,16 @@ public function convert(): StringSchema|IntegerSchema ), }; - /** @var non-empty-array $values */ - $values = array_column($this->enum::cases(), 'value'); - - $schema->enum($values); - - // Get the description from the doc parser - $description = $this->getDocParser()?->description() ?? null; + $values = $this->backedEnumValues($this->enum); - // Add the description to the schema if it exists - if ($description !== null) { - $schema->description($description); + if ($values === null) { + throw new SchemaException('Enum must be a backed enum'); } - return $schema; - } + $schema->enum($values); - protected function getDocParser(): ?DocParser - { - $docComment = $this->reflection->getDocComment(); + $this->applySchemaDocblock($schema, $this->docParser($this->reflection)); - return is_string($docComment) - ? new DocParser($docComment) - : null; + return $schema; } } diff --git a/src/Converters/JsonConverter.php b/src/Converters/JsonConverter.php index 84e13ed..a4a45e1 100644 --- a/src/Converters/JsonConverter.php +++ b/src/Converters/JsonConverter.php @@ -5,11 +5,11 @@ namespace Cortex\JsonSchema\Converters; use JsonException; -use ReflectionClass; use Cortex\JsonSchema\Enums\SchemaType; use Cortex\JsonSchema\Types\NullSchema; use Cortex\JsonSchema\Types\ArraySchema; use Cortex\JsonSchema\Types\UnionSchema; +use Cortex\JsonSchema\Support\SchemaData; use Cortex\JsonSchema\Types\NumberSchema; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Types\StringSchema; @@ -24,10 +24,7 @@ class JsonConverter implements Converter { - /** - * @var array - */ - private array $data; + private SchemaData $schemaData; private SchemaVersion $schemaVersion; @@ -48,13 +45,13 @@ public function __construct(string|array $json, SchemaVersion $schemaVersion) throw new SchemaException('Invalid JSON Schema: root must be an object'); } - $this->data = $decoded; + $this->schemaData = new SchemaData($decoded); } else { - $this->data = $json; + $this->schemaData = new SchemaData($json); } - if (isset($this->data['$schema']) && is_string($this->data['$schema'])) { - $this->schemaVersion = $this->detectSchemaVersion($this->data['$schema']); + if (($schemaUri = $this->schemaData->getString('$schema')) !== null) { + $this->schemaVersion = $this->detectSchemaVersion($schemaUri) ?? $schemaVersion; } else { $this->schemaVersion = $schemaVersion; } @@ -62,91 +59,56 @@ public function __construct(string|array $json, SchemaVersion $schemaVersion) public function convert(): JsonSchema { - $type = $this->data['type'] ?? null; - $title = isset($this->data['title']) && is_string($this->data['title']) ? $this->data['title'] : null; + $title = $this->schemaData->getString('title'); 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), - }; - } + $type = $this->resolveType(); - if (is_array($type)) { - return $this->createUnionSchema($title); - } - - return match ($type) { - 'string' => $this->createStringSchema($title), - 'number' => $this->createNumberSchema($title), - 'integer' => $this->createIntegerSchema($title), - 'boolean' => $this->createBooleanSchema($title), - 'array' => $this->createArraySchema($title), - '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)), - ), - }; + return $this->createFor($type, $title); } /** - * Safely get a string value from the data array. + * Resolve the schema type from an explicit type keyword or inferred keywords. */ - private function getString(string $key): ?string + private function resolveType(): ?SchemaType { - return isset($this->data[$key]) && is_string($this->data[$key]) ? $this->data[$key] : null; - } + $type = $this->schemaData->getValue('type'); - /** - * Safely get an integer value from the data array. - */ - private function getInt(string $key): ?int - { - return isset($this->data[$key]) && is_numeric($this->data[$key]) ? (int) $this->data[$key] : null; - } + if (is_array($type)) { + return null; + } - /** - * Safely get a float value from the data array. - */ - private function getFloat(string $key): ?float - { - return isset($this->data[$key]) && is_numeric($this->data[$key]) ? (float) $this->data[$key] : null; - } + if (is_string($type)) { + return SchemaType::tryFrom($type) ?? throw new SchemaException( + 'Unsupported schema type: ' . $type, + ); + } - /** - * Safely get a boolean value from the data array. - */ - private function getBool(string $key): bool - { - return isset($this->data[$key]) && (bool) $this->data[$key]; - } + if ($type === null) { + return $this->inferTypeFromKeywords(); + } - /** - * Safely get an array value from the data array. - * - * @return array|null - */ - private function getArray(string $key): ?array - { - return isset($this->data[$key]) && is_array($this->data[$key]) ? $this->data[$key] : null; + throw new SchemaException('Unsupported schema type: ' . gettype($type)); } /** - * Get a mixed value from the data array. + * Create a schema for the given type. */ - private function getValue(string $key): mixed + private function createFor(?SchemaType $schemaType, ?string $title): JsonSchema { - return $this->data[$key] ?? null; + return match ($schemaType) { + SchemaType::String => $this->createStringSchema($title), + SchemaType::Number => $this->createNumberSchema($title), + SchemaType::Integer => $this->createIntegerSchema($title), + SchemaType::Boolean => $this->createBooleanSchema($title), + SchemaType::Array => $this->createArraySchema($title), + SchemaType::Object => $this->createObjectSchema($title), + SchemaType::Null => $this->createNullSchema($title), + null => $this->createUnionSchema($title), + }; } /** @@ -154,55 +116,50 @@ private function getValue(string $key): mixed */ private function getBoolOrSchema(string $key): bool|JsonSchema|null { - $value = $this->getValue($key); + $value = $this->schemaData->getValue($key); if (is_bool($value)) { return $value; } - if (is_array($value)) { - return new self($value, $this->schemaVersion)->convert(); - } - - return null; + return $this->convertSubschema($value); } /** * Infer a schema type from present validation keywords when no explicit type is given. */ - private function inferTypeFromKeywords(): ?string + private function inferTypeFromKeywords(): ?SchemaType { - $stringKeywords = array_flip( - [ - 'pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType'], - ); - - if (array_intersect_key($this->data, $stringKeywords) !== []) { - return 'string'; + if ($this->schemaData->hasAny([ + 'pattern', 'minLength', 'maxLength', 'format', 'contentEncoding', 'contentMediaType', + ])) { + return SchemaType::String; } foreach (['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf'] as $keyword) { - if (array_key_exists($keyword, $this->data)) { - $value = $this->getValue($keyword); + if ($this->schemaData->has($keyword)) { + $value = $this->schemaData->getValue($keyword); - return is_int($value) || (is_float($value) && floor($value) === $value) ? 'integer' : 'number'; + return is_int($value) || (is_float($value) && floor($value) === $value) + ? SchemaType::Integer + : SchemaType::Number; } } - if (array_key_exists('items', $this->data) || array_key_exists('prefixItems', $this->data)) { - return 'array'; + if ($this->schemaData->has('items') || $this->schemaData->has('prefixItems')) { + return SchemaType::Array; } - if (array_key_exists('const', $this->data)) { - $const = $this->getValue('const'); + if ($this->schemaData->has('const')) { + $const = $this->schemaData->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', + is_string($const) => SchemaType::String, + is_int($const) => SchemaType::Integer, + is_float($const) => SchemaType::Number, + is_bool($const) => SchemaType::Boolean, + is_array($const) => SchemaType::Array, + $const === null => SchemaType::Null, default => null, }; } @@ -215,17 +172,15 @@ private function inferTypeFromKeywords(): ?string */ private function shouldUseTypelessSchema(): bool { - if (array_key_exists('type', $this->data)) { + if ($this->schemaData->has('type')) { return false; } - $structuralKeywords = array_flip([ + return $this->schemaData->hasAny([ '$ref', 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', '$defs', 'definitions', 'properties', 'patternProperties', 'dependentSchemas', 'dependentRequired', 'required', ]); - - return array_intersect_key($this->data, $structuralKeywords) !== []; } /** @@ -233,60 +188,60 @@ private function shouldUseTypelessSchema(): bool */ private function applyCommonKeywords(AbstractSchema $schema): void { - if (($id = $this->getString('$id')) !== null) { + if (($id = $this->schemaData->getString('$id')) !== null) { $schema->id($id); } - if (($anchor = $this->getString('$anchor')) !== null) { + if (($anchor = $this->schemaData->getString('$anchor')) !== null) { $schema->anchor($anchor); } - if (($description = $this->getString('description')) !== null) { + if (($description = $this->schemaData->getString('description')) !== null) { $schema->description($description); } - if (($comment = $this->getString('$comment')) !== null) { + if (($comment = $this->schemaData->getString('$comment')) !== null) { $schema->comment($comment); } - if (array_key_exists('default', $this->data)) { - $schema->default($this->getValue('default')); + if ($this->schemaData->has('default')) { + $schema->default($this->schemaData->getValue('default')); } - if ($this->getBool('deprecated')) { + if ($this->schemaData->getBool('deprecated')) { $schema->deprecated(); } - if ($this->getBool('readOnly')) { + if ($this->schemaData->getBool('readOnly')) { $schema->readOnly(); } - if ($this->getBool('writeOnly')) { + if ($this->schemaData->getBool('writeOnly')) { $schema->writeOnly(); } - if (($enum = $this->getArray('enum')) !== null && $enum !== []) { + if (($enum = $this->schemaData->getArray('enum')) !== null && $enum !== []) { /** @var non-empty-array $enum */ $schema->enum($enum); } - if (array_key_exists('const', $this->data)) { - $const = $this->getValue('const'); + if ($this->schemaData->has('const')) { + $const = $this->schemaData->getValue('const'); if (is_bool($const) || is_float($const) || is_int($const) || is_string($const) || $const === null) { $schema->const($const); } } - if (($examples = $this->getArray('examples')) !== null) { + if (($examples = $this->schemaData->getArray('examples')) !== null) { $schema->examples($examples); } - if (($format = $this->getString('format')) !== null) { + if (($format = $this->schemaData->getString('format')) !== null) { $schema->format($format); } - if (($ref = $this->getString('$ref')) !== null) { + if (($ref = $this->schemaData->getString('$ref')) !== null) { $schema->ref($ref); } @@ -311,18 +266,18 @@ private function applyConditionals(AbstractSchema $schema): void $schema->oneOf(...$oneOf); } - if (($not = $this->convertSubschema($this->getValue('not'))) instanceof JsonSchema) { + if (($not = $this->convertSubschema($this->schemaData->getValue('not'))) instanceof JsonSchema) { $schema->not($not); } - if (($if = $this->convertSubschema($this->getValue('if'))) instanceof JsonSchema) { + if (($if = $this->convertSubschema($this->schemaData->getValue('if'))) instanceof JsonSchema) { $schema->if($if); - if (($then = $this->convertSubschema($this->getValue('then'))) instanceof JsonSchema) { + if (($then = $this->convertSubschema($this->schemaData->getValue('then'))) instanceof JsonSchema) { $schema->then($then); } - if (($else = $this->convertSubschema($this->getValue('else'))) instanceof JsonSchema) { + if (($else = $this->convertSubschema($this->schemaData->getValue('else'))) instanceof JsonSchema) { $schema->else($else); } } @@ -333,7 +288,7 @@ private function applyConditionals(AbstractSchema $schema): void */ private function applyDefinitions(AbstractSchema $schema): void { - $definitions = $this->getArray('$defs') ?? $this->getArray('definitions'); + $definitions = $this->schemaData->getArray('$defs') ?? $this->schemaData->getArray('definitions'); if ($definitions === null) { return; @@ -344,11 +299,11 @@ private function applyDefinitions(AbstractSchema $schema): void continue; } - if (! is_array($definitionData)) { - continue; - } + $definition = $this->convertSubschema($definitionData); - $schema->addDefinition($name, new self($definitionData, $this->schemaVersion)->convert()); + if ($definition instanceof JsonSchema) { + $schema->addDefinition($name, $definition); + } } } @@ -357,56 +312,48 @@ private function applyDefinitions(AbstractSchema $schema): void */ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $objectSchema): void { - $required = $this->getArray('required') ?? []; - - if (($properties = $this->getArray('properties')) !== null) { - $propertySchemas = []; - $requiredProps = []; + $required = $this->schemaData->getArray('required') ?? []; + if (($properties = $this->schemaData->getArray('properties')) !== null) { foreach ($properties as $name => $propertyData) { if (! is_string($name)) { continue; } - if (! is_array($propertyData)) { + $propertySchema = $this->convertSubschema($propertyData); + + if (! $propertySchema instanceof JsonSchema) { continue; } - $propertySchemas[$name] = new self($propertyData, $this->schemaVersion)->convert(); - - if (in_array($name, $required, true)) { - $requiredProps[] = $name; - } + $objectSchema->property($name, $propertySchema, in_array($name, $required, true)); } - - $reflection = new ReflectionClass($objectSchema); - $reflection->getProperty('properties')->setValue($objectSchema, $propertySchemas); - $reflection->getProperty('requiredProperties')->setValue($objectSchema, $requiredProps); } elseif ($required !== []) { $requiredProps = array_values(array_filter($required, is_string(...))); if ($requiredProps !== []) { - $reflection = new ReflectionClass($objectSchema); - $reflection->getProperty('requiredProperties')->setValue($objectSchema, $requiredProps); + $objectSchema->requireProperties(...$requiredProps); } } - if (($patternProperties = $this->getArray('patternProperties')) !== null) { + if (($patternProperties = $this->schemaData->getArray('patternProperties')) !== null) { foreach ($patternProperties as $pattern => $propertyData) { if (! is_string($pattern)) { continue; } - if (! is_array($propertyData)) { - continue; - } + $propertySchema = $this->convertSubschema($propertyData); - $objectSchema->patternProperty($pattern, new self($propertyData, $this->schemaVersion)->convert()); + if ($propertySchema instanceof JsonSchema) { + $objectSchema->patternProperty($pattern, $propertySchema); + } } } - if (($propertyNames = $this->getArray('propertyNames')) !== null) { - $objectSchema->propertyNames(new self($propertyNames, $this->schemaVersion)->convert()); + if (($propertyNames = $this->convertSubschema( + $this->schemaData->getValue('propertyNames'), + )) instanceof JsonSchema) { + $objectSchema->propertyNames($propertyNames); } if (($additionalProperties = $this->getBoolOrSchema('additionalProperties')) !== null) { @@ -417,21 +364,21 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $ob $objectSchema->unevaluatedProperties($unevaluatedProperties); } - if (($dependentSchemas = $this->getArray('dependentSchemas')) !== null) { + if (($dependentSchemas = $this->schemaData->getArray('dependentSchemas')) !== null) { foreach ($dependentSchemas as $property => $dependentData) { if (! is_string($property)) { continue; } - if (! is_array($dependentData)) { - continue; - } + $dependentSchema = $this->convertSubschema($dependentData); - $objectSchema->dependentSchema($property, new self($dependentData, $this->schemaVersion)->convert()); + if ($dependentSchema instanceof JsonSchema) { + $objectSchema->dependentSchema($property, $dependentSchema); + } } } - if (($dependentRequired = $this->getArray('dependentRequired')) !== null) { + if (($dependentRequired = $this->schemaData->getArray('dependentRequired')) !== null) { /** @var array> $normalized */ $normalized = []; @@ -452,11 +399,11 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $ob } } - if (($minProperties = $this->getInt('minProperties')) !== null) { + if (($minProperties = $this->schemaData->getInt('minProperties')) !== null) { $objectSchema->minProperties($minProperties); } - if (($maxProperties = $this->getInt('maxProperties')) !== null) { + if (($maxProperties = $this->schemaData->getInt('maxProperties')) !== null) { $objectSchema->maxProperties($maxProperties); } } @@ -466,20 +413,19 @@ private function applyObjectKeywords(ObjectSchema|UnionSchema|TypelessSchema $ob */ private function applyArrayKeywords(ArraySchema $arraySchema): void { - $items = $this->getValue('items'); + $items = $this->schemaData->getValue('items'); if (is_array($items)) { if (array_is_list($items)) { - $tupleSchemas = array_values(array_map( - fn(array $item): JsonSchema => new self($item, $this->schemaVersion)->convert(), - array_filter($items, is_array(...)), + $tupleSchemas = array_values(array_filter( + array_map($this->convertSubschema(...), $items), )); if ($tupleSchemas !== []) { $arraySchema->tupleItems($tupleSchemas); } - } else { - $arraySchema->items(new self($items, $this->schemaVersion)->convert()); + } elseif (($itemsSchema = $this->convertSubschema($items)) instanceof JsonSchema) { + $arraySchema->items($itemsSchema); } } @@ -487,10 +433,9 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void $arraySchema->additionalItems($additionalItems); } - if (($prefixItems = $this->getArray('prefixItems')) !== null && array_is_list($prefixItems)) { - $prefixSchemas = array_values(array_map( - fn(array $item): JsonSchema => new self($item, $this->schemaVersion)->convert(), - array_filter($prefixItems, is_array(...)), + if (($prefixItems = $this->schemaData->getArray('prefixItems')) !== null && array_is_list($prefixItems)) { + $prefixSchemas = array_values(array_filter( + array_map($this->convertSubschema(...), $prefixItems), )); if ($prefixSchemas !== []) { @@ -498,27 +443,27 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void } } - if (($minItems = $this->getInt('minItems')) !== null) { + if (($minItems = $this->schemaData->getInt('minItems')) !== null) { $arraySchema->minItems($minItems); } - if (($maxItems = $this->getInt('maxItems')) !== null) { + if (($maxItems = $this->schemaData->getInt('maxItems')) !== null) { $arraySchema->maxItems($maxItems); } - if ($this->getBool('uniqueItems')) { + if ($this->schemaData->getBool('uniqueItems')) { $arraySchema->uniqueItems(); } - if (($contains = $this->getArray('contains')) !== null) { - $arraySchema->contains(new self($contains, $this->schemaVersion)->convert()); + if (($contains = $this->convertSubschema($this->schemaData->getValue('contains'))) instanceof JsonSchema) { + $arraySchema->contains($contains); } - if (($minContains = $this->getInt('minContains')) !== null) { + if (($minContains = $this->schemaData->getInt('minContains')) !== null) { $arraySchema->minContains($minContains); } - if (($maxContains = $this->getInt('maxContains')) !== null) { + if (($maxContains = $this->schemaData->getInt('maxContains')) !== null) { $arraySchema->maxContains($maxContains); } @@ -528,55 +473,53 @@ private function applyArrayKeywords(ArraySchema $arraySchema): void } /** - * Apply numeric constraint keywords. + * Apply integer-specific numeric constraints. */ - private function applyNumericKeywords(AbstractSchema $schema): void + private function applyIntegerConstraints(IntegerSchema $integerSchema): 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 (($minimum = $this->schemaData->getInt('minimum')) !== null) { + $integerSchema->minimum($minimum); + } - if (($exclusiveMaximum = $this->getInt('exclusiveMaximum')) !== null) { - $schema->exclusiveMaximum($exclusiveMaximum); - } + if (($maximum = $this->schemaData->getInt('maximum')) !== null) { + $integerSchema->maximum($maximum); + } - if (($multipleOf = $this->getInt('multipleOf')) !== null) { - $schema->multipleOf($multipleOf); - } + if (($exclusiveMinimum = $this->schemaData->getInt('exclusiveMinimum')) !== null) { + $integerSchema->exclusiveMinimum($exclusiveMinimum); + } - return; + if (($exclusiveMaximum = $this->schemaData->getInt('exclusiveMaximum')) !== null) { + $integerSchema->exclusiveMaximum($exclusiveMaximum); } - if (! $schema instanceof NumberSchema && ! $schema instanceof UnionSchema && ! $schema instanceof TypelessSchema) { - return; + if (($multipleOf = $this->schemaData->getInt('multipleOf')) !== null) { + $integerSchema->multipleOf($multipleOf); } + } - if (($minimum = $this->getFloat('minimum')) !== null) { + /** + * Apply float numeric constraints. + */ + private function applyNumberConstraints(NumberSchema|UnionSchema|TypelessSchema $schema): void + { + if (($minimum = $this->schemaData->getFloat('minimum')) !== null) { $schema->minimum($minimum); } - if (($maximum = $this->getFloat('maximum')) !== null) { + if (($maximum = $this->schemaData->getFloat('maximum')) !== null) { $schema->maximum($maximum); } - if (($exclusiveMinimum = $this->getFloat('exclusiveMinimum')) !== null) { + if (($exclusiveMinimum = $this->schemaData->getFloat('exclusiveMinimum')) !== null) { $schema->exclusiveMinimum($exclusiveMinimum); } - if (($exclusiveMaximum = $this->getFloat('exclusiveMaximum')) !== null) { + if (($exclusiveMaximum = $this->schemaData->getFloat('exclusiveMaximum')) !== null) { $schema->exclusiveMaximum($exclusiveMaximum); } - if (($multipleOf = $this->getFloat('multipleOf')) !== null) { + if (($multipleOf = $this->schemaData->getFloat('multipleOf')) !== null) { $schema->multipleOf($multipleOf); } } @@ -584,14 +527,14 @@ private function applyNumericKeywords(AbstractSchema $schema): void /** * Detect schema version from a $schema URI. */ - private function detectSchemaVersion(string $schemaUri): SchemaVersion + 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, + default => null, }; } @@ -614,15 +557,14 @@ private function convertSubschema(mixed $value): ?JsonSchema */ private function getArrayOfSchemas(string $key): array { - $value = $this->getArray($key); + $value = $this->schemaData->getArray($key); if ($value === null || ! array_is_list($value)) { return []; } - return array_values(array_map( - fn(array $item): JsonSchema => new self($item, $this->schemaVersion)->convert(), - array_filter($value, is_array(...)), + return array_values(array_filter( + array_map($this->convertSubschema(...), $value), )); } @@ -631,9 +573,7 @@ private function createTypelessSchema(?string $title): TypelessSchema $typelessSchema = new TypelessSchema($title, $this->schemaVersion); $this->applyCommonKeywords($typelessSchema); - $objectKeywords = array_flip(['properties', 'patternProperties', 'required']); - - if (array_intersect_key($this->data, $objectKeywords) !== []) { + if ($this->schemaData->hasAny(['properties', 'patternProperties', 'required'])) { $this->applyObjectKeywords($typelessSchema); } @@ -645,32 +585,32 @@ private function createStringSchema(?string $title): StringSchema $stringSchema = new StringSchema($title, $this->schemaVersion); $this->applyCommonKeywords($stringSchema); - if (($minLength = $this->getInt('minLength')) !== null) { + if (($minLength = $this->schemaData->getInt('minLength')) !== null) { $stringSchema->minLength($minLength); } - if (($maxLength = $this->getInt('maxLength')) !== null) { + if (($maxLength = $this->schemaData->getInt('maxLength')) !== null) { $stringSchema->maxLength($maxLength); } - if (($pattern = $this->getString('pattern')) !== null) { + if (($pattern = $this->schemaData->getString('pattern')) !== null) { $stringSchema->pattern($pattern); } - if (($contentEncoding = $this->getString('contentEncoding')) !== null) { + if (($contentEncoding = $this->schemaData->getString('contentEncoding')) !== null) { $stringSchema->contentEncoding($contentEncoding); } - if (($contentMediaType = $this->getString('contentMediaType')) !== null) { + if (($contentMediaType = $this->schemaData->getString('contentMediaType')) !== null) { $stringSchema->contentMediaType($contentMediaType); } - $contentSchema = $this->getValue('contentSchema'); + $contentSchema = $this->schemaData->getValue('contentSchema'); - if (is_array($contentSchema)) { - $stringSchema->contentSchema(new self($contentSchema, $this->schemaVersion)->convert()); - } elseif (is_bool($contentSchema)) { + if (is_bool($contentSchema)) { $stringSchema->contentSchema($contentSchema); + } elseif (($converted = $this->convertSubschema($contentSchema)) instanceof JsonSchema) { + $stringSchema->contentSchema($converted); } return $stringSchema; @@ -680,7 +620,7 @@ private function createNumberSchema(?string $title): NumberSchema { $numberSchema = new NumberSchema($title, $this->schemaVersion); $this->applyCommonKeywords($numberSchema); - $this->applyNumericKeywords($numberSchema); + $this->applyNumberConstraints($numberSchema); return $numberSchema; } @@ -689,7 +629,7 @@ private function createIntegerSchema(?string $title): IntegerSchema { $integerSchema = new IntegerSchema($title, $this->schemaVersion); $this->applyCommonKeywords($integerSchema); - $this->applyNumericKeywords($integerSchema); + $this->applyIntegerConstraints($integerSchema); return $integerSchema; } @@ -730,7 +670,7 @@ private function createNullSchema(?string $title): NullSchema private function createUnionSchema(?string $title): UnionSchema { - $typeData = $this->getValue('type'); + $typeData = $this->schemaData->getValue('type'); if (is_array($typeData)) { $types = array_values(array_map( @@ -743,7 +683,7 @@ private function createUnionSchema(?string $title): UnionSchema } $this->applyCommonKeywords($schema); - $this->applyNumericKeywords($schema); + $this->applyNumberConstraints($schema); return $schema; } diff --git a/src/Support/SchemaData.php b/src/Support/SchemaData.php new file mode 100644 index 0000000..a5015e2 --- /dev/null +++ b/src/Support/SchemaData.php @@ -0,0 +1,96 @@ + $data + */ + public function __construct( + private array $data, + ) {} + + /** + * Determine whether a key exists in the data. + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } + + /** + * Get the full data array. + * + * @return array + */ + public function all(): array + { + return $this->data; + } + + /** + * Safely get a string value from the data array. + */ + public function getString(string $key): ?string + { + return isset($this->data[$key]) && is_string($this->data[$key]) ? $this->data[$key] : null; + } + + /** + * Safely get an integer value from the data array. + */ + public function getInt(string $key): ?int + { + return isset($this->data[$key]) && is_numeric($this->data[$key]) ? (int) $this->data[$key] : null; + } + + /** + * Safely get a float value from the data array. + */ + public function getFloat(string $key): ?float + { + return isset($this->data[$key]) && is_numeric($this->data[$key]) ? (float) $this->data[$key] : null; + } + + /** + * Safely get a boolean value from the data array. + */ + public function getBool(string $key): bool + { + return isset($this->data[$key]) && (bool) $this->data[$key]; + } + + /** + * Safely get an array value from the data array. + * + * @return array|null + */ + public function getArray(string $key): ?array + { + return isset($this->data[$key]) && is_array($this->data[$key]) ? $this->data[$key] : null; + } + + /** + * Get a mixed value from the data array. + */ + public function getValue(string $key): mixed + { + return $this->data[$key] ?? null; + } + + /** + * Determine whether any of the given keys are present. + * + * @param list $keys + */ + public function hasAny(array $keys): bool + { + return array_intersect_key($this->data, array_flip($keys)) !== []; + } +} diff --git a/src/Types/Concerns/HasProperties.php b/src/Types/Concerns/HasProperties.php index edea723..a2d7dec 100644 --- a/src/Types/Concerns/HasProperties.php +++ b/src/Types/Concerns/HasProperties.php @@ -68,11 +68,33 @@ public function properties(JsonSchema ...$properties): static throw new SchemaException('Property must have a title'); } - $this->properties[$title] = $property; + $this->property($title, $property, $property->isRequired()); + } - if ($property->isRequired()) { - $this->requiredProperties[] = $title; - } + return $this; + } + + /** + * Set a named property, optionally marking it as required. + */ + public function property(string $name, JsonSchema $jsonSchema, bool $required = false): static + { + $this->properties[$name] = $jsonSchema; + + if ($required) { + $this->requiredProperties[] = $name; + } + + return $this; + } + + /** + * Mark the given property names as required. + */ + public function requireProperties(string ...$names): static + { + foreach ($names as $name) { + $this->requiredProperties[] = $name; } return $this; From bd1e62d3957a1dbd963631e380c0dd537117d852 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 6 Aug 2026 23:20:13 +0100 Subject: [PATCH 7/8] add back mutation tests --- .github/workflows/run-tests.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6d1bb52..b9fcaff 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -30,7 +30,6 @@ jobs: uses: ./.github/actions/setup-php-composer with: php-version: ${{ matrix.php }} - coverage: pcov stability: ${{ matrix.stability }} - name: Setup problem matchers @@ -38,10 +37,23 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/php.json" echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - # - name: Execute tests with mutation - # if: ${{ matrix.os == 'ubuntu-latest' }} - # run: vendor/bin/pest --colors=always --mutate --parallel --min=80 - - name: Execute tests - # if: ${{ matrix.os != 'ubuntu-latest' }} run: vendor/bin/pest --colors=always + + mutation: + runs-on: ubuntu-latest + + name: Mutation + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP and Composer + uses: ./.github/actions/setup-php-composer + with: + php-version: 8.5 + coverage: pcov + + - name: Execute mutation tests + run: vendor/bin/pest --colors=always --mutate --parallel --min=80 From a684da6bca5d5c05122770c95eaf52bd20da2429 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Thu, 6 Aug 2026 23:23:03 +0100 Subject: [PATCH 8/8] fix --- .github/workflows/run-tests.yml | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b9fcaff..243fa32 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,11 +14,16 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] php: [8.4, 8.5] stability: [prefer-lowest, prefer-stable] + include: + - os: ubuntu-latest + php: 8.5 + stability: prefer-stable + mutation: true name: PHP${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} @@ -30,6 +35,7 @@ jobs: uses: ./.github/actions/setup-php-composer with: php-version: ${{ matrix.php }} + coverage: ${{ matrix.mutation && 'pcov' || 'none' }} stability: ${{ matrix.stability }} - name: Setup problem matchers @@ -38,22 +44,9 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Execute tests + if: ${{ ! matrix.mutation }} run: vendor/bin/pest --colors=always - mutation: - runs-on: ubuntu-latest - - name: Mutation - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup PHP and Composer - uses: ./.github/actions/setup-php-composer - with: - php-version: 8.5 - coverage: pcov - - - name: Execute mutation tests + - name: Execute tests with mutation + if: ${{ matrix.mutation }} run: vendor/bin/pest --colors=always --mutate --parallel --min=80