From 458ee97122ac758e95e4c77a3b88a3c25d7e268e Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Mon, 20 Jan 2025 23:24:49 +0000 Subject: [PATCH] feat: Add refs and definitions (#1) --- README.md | 279 +++++++++++++++++++++++++- src/Types/AbstractSchema.php | 6 + src/Types/Concerns/HasDefinitions.php | 67 +++++++ src/Types/Concerns/HasRef.php | 36 ++++ tests/Unit/DefinitionsSchemaTest.php | 214 ++++++++++++++++++++ tests/Unit/RefSchemaTest.php | 30 +++ 6 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 src/Types/Concerns/HasDefinitions.php create mode 100644 src/Types/Concerns/HasRef.php create mode 100644 tests/Unit/DefinitionsSchemaTest.php create mode 100644 tests/Unit/RefSchemaTest.php diff --git a/README.md b/README.md index 61fe29b..5801447 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,10 @@ $schema = SchemaFactory::object('user') ->default(true), SchemaFactory::object('settings') ->additionalProperties(false) - ->properties([ + ->properties( SchemaFactory::string('theme') ->enum(['light', 'dark']), - ]), + ), ); ``` @@ -330,6 +330,45 @@ $schema->isValid(['foo', 'bar', 'baz', 'qux']); // false (too many items) ``` +Arrays also support validation of specific items using `contains`: + +```php +use Cortex\JsonSchema\SchemaFactory; + +// Array must contain at least one number between 10 and 20 +$schema = SchemaFactory::array('numbers') + ->contains( + SchemaFactory::number() + ->minimum(10) + ->maximum(20) + ) + ->minContains(2) // must contain at least 2 such numbers + ->maxContains(3); // must contain at most 3 such numbers +``` + +```php +$schema->isValid([15, 12, 18]); // true (contains 3 numbers between 10-20) +$schema->isValid([15, 5, 25]); // false (only contains 1 number between 10-20) +$schema->isValid([15, 12, 18, 19]); // false (contains 4 numbers between 10-20) +``` + +You can also validate tuple-like arrays with different schemas for specific positions: + +```php +use Cortex\JsonSchema\SchemaFactory; + +$schema = SchemaFactory::array('coordinates') + ->items( + SchemaFactory::number()->description('latitude'), + SchemaFactory::number()->description('longitude'), + ); +``` + +```php +$schema->isValid([51.5074, -0.1278]); // true (valid lat/long) +$schema->isValid(['invalid', -0.1278]); // false (first item must be number) +``` + --- ### Object Schema @@ -373,6 +412,28 @@ $schema->isValid([ ]); // false (additional properties) ``` +Objects support additional validation features: + +```php +use Cortex\JsonSchema\SchemaFactory; + +$schema = SchemaFactory::object('config') + // Validate property names against a pattern + ->propertyNames( + SchemaFactory::string()->pattern('^[a-zA-Z]+$') + ) + // Control number of properties + ->minProperties(1) + ->maxProperties(10) + ->additionalProperties(false); +``` + +```php +// Property names must be alphabetic +$schema->isValid(['123' => 'value']); // false +$schema->isValid(['validKey' => 'value']); // true +``` +
View JSON Schema @@ -445,6 +506,220 @@ $schema->isValid('invalid'); // false (not in enum) --- +### String Formats + +Strings can be validated against various formats: + +```php +use Cortex\JsonSchema\SchemaFactory; +use Cortex\JsonSchema\Enums\SchemaFormat; + +$schema = SchemaFactory::object('user') + ->properties( + SchemaFactory::string('email')->format(SchemaFormat::Email), + SchemaFactory::string('website')->format(SchemaFormat::Uri), + SchemaFactory::string('hostname')->format(SchemaFormat::Hostname), + SchemaFactory::string('ipv4')->format(SchemaFormat::Ipv4), + SchemaFactory::string('ipv6')->format(SchemaFormat::Ipv6), + SchemaFactory::string('date')->format(SchemaFormat::Date), + SchemaFactory::string('time')->format(SchemaFormat::Time), + SchemaFactory::string('date_time')->format(SchemaFormat::DateTime), + SchemaFactory::string('duration')->format(SchemaFormat::Duration), + SchemaFactory::string('json_pointer')->format(SchemaFormat::JsonPointer), + SchemaFactory::string('relative_json_pointer')->format(SchemaFormat::RelativeJsonPointer), + SchemaFactory::string('uri_template')->format(SchemaFormat::UriTemplate), + SchemaFactory::string('idn_email')->format(SchemaFormat::IdnEmail), + SchemaFactory::string('idn_hostname')->format(SchemaFormat::Hostname), + SchemaFactory::string('iri')->format(SchemaFormat::Iri), + SchemaFactory::string('iri_reference')->format(SchemaFormat::IriReference), + ); +``` + +--- + +### Conditional Validation + +The schema specification supports several types of conditional validation: + +```php +use Cortex\JsonSchema\SchemaFactory; + +// if/then/else condition +$schema = SchemaFactory::object('user') + ->properties( + SchemaFactory::string('type')->enum(['personal', 'business']), + SchemaFactory::string('company_name'), + SchemaFactory::string('tax_id'), + ) + ->if( + SchemaFactory::object()->properties( + SchemaFactory::string('type')->const('business'), + ), + ) + ->then( + SchemaFactory::object()->properties( + SchemaFactory::string('company_name')->required(), + SchemaFactory::string('tax_id')->required(), + ), + ) + ->else( + SchemaFactory::object()->properties( + SchemaFactory::string('company_name')->const(null), + SchemaFactory::string('tax_id')->const(null), + ), + ); + +// allOf - all schemas must match +$schema = SchemaFactory::object() + ->allOf( + SchemaFactory::object() + ->properties( + SchemaFactory::string('name')->required(), + ), + SchemaFactory::object() + ->properties( + SchemaFactory::integer('age') + ->minimum(18) + ->required(), + ), + ); + +// anyOf - at least one schema must match +$schema = SchemaFactory::object('payment') + ->anyOf( + SchemaFactory::object() + ->properties( + SchemaFactory::string('credit_card') + ->pattern('^\d{16}$') + ->required(), + ), + SchemaFactory::object() + ->properties( + SchemaFactory::string('bank_transfer') + ->pattern('^\w{8,}$') + ->required(), + ), + ); + +// oneOf - exactly one schema must match +$schema = SchemaFactory::object('contact') + ->oneOf( + SchemaFactory::object() + ->properties( + SchemaFactory::string('email') + ->format(SchemaFormat::Email) + ->required(), + ), + SchemaFactory::object() + ->properties( + SchemaFactory::string('phone') + ->pattern('^\+\d{10,}$') + ->required(), + ), + ); + +// not - schema must not match +$schema = SchemaFactory::string('status') + ->not( + SchemaFactory::string() + ->enum(['deleted', 'banned']), + ); +``` + +--- + +### Schema Definitions & References + +You can define reusable schema components using definitions and reference them using `$ref`: + +```php +use Cortex\JsonSchema\SchemaFactory; + +$schema = SchemaFactory::object('user') + // Define a reusable address schema + ->addDefinition( + 'address', + SchemaFactory::object() + ->properties( + SchemaFactory::string('street')->required(), + SchemaFactory::string('city')->required(), + SchemaFactory::string('country')->required(), + ), + ) + // Use the address schema multiple times via $ref + ->properties( + SchemaFactory::string('name')->required(), + SchemaFactory::object('billing_address') + ->ref('#/definitions/address') + ->required(), + SchemaFactory::object('shipping_address') + ->ref('#/definitions/address') + ->required(), + ); +``` + +You can also add multiple definitions at once: + +```php +$schema = SchemaFactory::object('user') + ->addDefinitions([ + 'address' => SchemaFactory::object() + ->properties( + SchemaFactory::string('street')->required(), + SchemaFactory::string('city')->required(), + ), + 'contact' => SchemaFactory::object() + ->properties( + SchemaFactory::string('email') + ->format(SchemaFormat::Email) + ->required(), + SchemaFactory::string('phone'), + ), + ]); +``` + +The resulting JSON Schema will include both the definitions and references: + +
+View JSON Schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "user", + "definitions": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + } + }, + "required": ["street", "city"] + } + }, + "properties": { + "name": { + "type": "string" + }, + "billing_address": { + "$ref": "#/definitions/address" + }, + "shipping_address": { + "$ref": "#/definitions/address" + } + }, + "required": ["name", "billing_address", "shipping_address"] +} +``` +
+ +--- + ## Validation The validate method throws a `SchemaException` when validation fails: diff --git a/src/Types/AbstractSchema.php b/src/Types/AbstractSchema.php index 67b2437..a1d2150 100644 --- a/src/Types/AbstractSchema.php +++ b/src/Types/AbstractSchema.php @@ -6,6 +6,7 @@ use Cortex\JsonSchema\Contracts\Schema; use Cortex\JsonSchema\Enums\SchemaType; +use Cortex\JsonSchema\Types\Concerns\HasRef; use Cortex\JsonSchema\Types\Concerns\HasEnum; use Cortex\JsonSchema\Types\Concerns\HasConst; use Cortex\JsonSchema\Types\Concerns\HasTitle; @@ -14,11 +15,13 @@ use Cortex\JsonSchema\Types\Concerns\HasRequired; use Cortex\JsonSchema\Types\Concerns\HasReadWrite; use Cortex\JsonSchema\Types\Concerns\HasValidation; +use Cortex\JsonSchema\Types\Concerns\HasDefinitions; use Cortex\JsonSchema\Types\Concerns\HasDescription; use Cortex\JsonSchema\Types\Concerns\HasConditionals; abstract class AbstractSchema implements Schema { + use HasRef; use HasEnum; use HasConst; use HasTitle; @@ -29,6 +32,7 @@ abstract class AbstractSchema implements Schema use HasValidation; use HasDescription; use HasConditionals; + use HasDefinitions; protected string $schemaVersion = 'http://json-schema.org/draft-07/schema#'; @@ -95,6 +99,8 @@ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true $schema = $this->addConstToSchema($schema); $schema = $this->addConditionalsToSchema($schema); $schema = $this->addMetadataToSchema($schema); + $schema = $this->addRefToSchema($schema); + $schema = $this->addDefinitionsToSchema($schema); return $this->addReadWriteToSchema($schema); } diff --git a/src/Types/Concerns/HasDefinitions.php b/src/Types/Concerns/HasDefinitions.php new file mode 100644 index 0000000..6bdcf81 --- /dev/null +++ b/src/Types/Concerns/HasDefinitions.php @@ -0,0 +1,67 @@ + + */ + protected array $definitions = []; + + /** + * Add a definition to the schema. + */ + public function addDefinition(string $name, Schema $schema): static + { + $this->definitions[$name] = $schema; + + return $this; + } + + /** + * Add multiple definitions to the schema. + * + * @param array $definitions + */ + public function addDefinitions(array $definitions): static + { + foreach ($definitions as $name => $schema) { + $this->addDefinition($name, $schema); + } + + return $this; + } + + /** + * Get a definition from the schema. + */ + public function getDefinition(string $name): ?Schema + { + return $this->definitions[$name] ?? null; + } + + /** + * Add definitions to schema array. + * + * @param array $schema + * + * @return array + */ + protected function addDefinitionsToSchema(array $schema): array + { + if ($this->definitions !== []) { + $schema['definitions'] = []; + + foreach ($this->definitions as $name => $definition) { + $schema['definitions'][$name] = $definition->toArray(includeSchemaRef: false); + } + } + + return $schema; + } +} diff --git a/src/Types/Concerns/HasRef.php b/src/Types/Concerns/HasRef.php new file mode 100644 index 0000000..29a62b0 --- /dev/null +++ b/src/Types/Concerns/HasRef.php @@ -0,0 +1,36 @@ +ref = $ref; + + return $this; + } + + /** + * Add $ref to schema array. + * + * @param array $schema + * + * @return array + */ + protected function addRefToSchema(array $schema): array + { + if ($this->ref !== null) { + $schema['$ref'] = $this->ref; + } + + return $schema; + } +} diff --git a/tests/Unit/DefinitionsSchemaTest.php b/tests/Unit/DefinitionsSchemaTest.php new file mode 100644 index 0000000..54838f6 --- /dev/null +++ b/tests/Unit/DefinitionsSchemaTest.php @@ -0,0 +1,214 @@ +addDefinition( + 'address', + Schema::object() + ->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), + Schema::string('country')->required(), + ), + ); + + $schemaArray = $schema->toArray(); + + expect($schemaArray)->toHaveKey('definitions.address'); + expect($schemaArray['definitions']['address'])->toHaveKey('type', 'object'); + expect($schemaArray['definitions']['address'])->toHaveKey('required', ['street', 'city', 'country']); +}); + +it('can add multiple definitions to a schema', function (): void { + $schema = Schema::object('user') + ->addDefinitions([ + 'address' => Schema::object() + ->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), + ), + 'contact' => Schema::object() + ->properties( + Schema::string('email') + ->format(SchemaFormat::Email) + ->required(), + Schema::string('phone'), + ), + ]); + + $schemaArray = $schema->toArray(); + + expect($schemaArray)->toHaveKey('definitions.address'); + expect($schemaArray)->toHaveKey('definitions.contact'); + expect($schemaArray['definitions']['address'])->toHaveKey('required', ['street', 'city']); + expect($schemaArray['definitions']['contact'])->toHaveKey('required', ['email']); +}); + +it('can reference a definition in a schema property', function (): void { + $schema = Schema::object('user') + ->addDefinition( + 'address', + Schema::object() + ->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), + ), + ) + ->properties( + Schema::string('name')->required(), + Schema::object('billing_address') + ->ref('#/definitions/address'), + Schema::object('shipping_address') + ->ref('#/definitions/address'), + ); + + $schemaArray = $schema->toArray(); + + expect($schemaArray)->toHaveKey('definitions.address'); + expect($schemaArray)->toHaveKey('properties.billing_address.$ref', '#/definitions/address'); + expect($schemaArray)->toHaveKey('properties.shipping_address.$ref', '#/definitions/address'); +}); + +it('validates data against a schema with referenced definitions', function (): void { + $schema = Schema::object('user') + ->addDefinition( + 'address', + Schema::object() + ->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), + ), + ) + ->properties( + Schema::string('name')->required(), + Schema::object('billing_address') + ->ref('#/definitions/address') + ->required(), + Schema::object('shipping_address') + ->ref('#/definitions/address') + ->required(), + ); + + // Test valid data + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'billing_address' => [ + 'street' => '123 Main St', + 'city' => 'New York', + ], + 'shipping_address' => [ + 'street' => '456 Market St', + 'city' => 'San Francisco', + ], + ]))->not->toThrow(SchemaException::class); + + // Test missing required field in referenced schema + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'billing_address' => [ + 'street' => '123 Main St', + // missing city + ], + 'shipping_address' => [ + 'street' => '456 Market St', + 'city' => 'San Francisco', + ], + ]))->toThrow(SchemaException::class); + + // Test invalid type in referenced schema + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'billing_address' => [ + 'street' => 123, // should be string + 'city' => 'New York', + ], + 'shipping_address' => [ + 'street' => '456 Market St', + 'city' => 'San Francisco', + ], + ]))->toThrow(SchemaException::class); + + // Test missing required referenced object + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'billing_address' => [ + 'street' => '123 Main St', + 'city' => 'New York', + ], + // missing shipping_address + ]))->toThrow(SchemaException::class); +}); + +it('validates data against a schema with multiple referenced definitions', function (): void { + $schema = Schema::object('user') + ->addDefinitions([ + 'contact' => Schema::object() + ->properties( + Schema::string('email') + ->format(SchemaFormat::Email) + ->required(), + Schema::string('phone'), + ), + 'address' => Schema::object() + ->properties( + Schema::string('street')->required(), + Schema::string('city')->required(), + ), + ]) + ->properties( + Schema::string('name')->required(), + Schema::object('primary_contact') + ->ref('#/definitions/contact') + ->required(), + Schema::object('address') + ->ref('#/definitions/address') + ->required(), + ); + + // Test valid data + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'primary_contact' => [ + 'email' => 'john@example.com', + 'phone' => '+1234567890', + ], + 'address' => [ + 'street' => '123 Main St', + 'city' => 'New York', + ], + ]))->not->toThrow(SchemaException::class); + + // Test valid data with optional fields omitted + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'primary_contact' => [ + 'email' => 'john@example.com', + // phone is optional + ], + 'address' => [ + 'street' => '123 Main St', + 'city' => 'New York', + ], + ]))->not->toThrow(SchemaException::class); + + // Test invalid email in contact definition + expect(fn() => $schema->validate([ + 'name' => 'John Doe', + 'primary_contact' => [ + 'email' => 'not-an-email', + 'phone' => '+1234567890', + ], + 'address' => [ + 'street' => '123 Main St', + 'city' => 'New York', + ], + ]))->toThrow(SchemaException::class); +}); diff --git a/tests/Unit/RefSchemaTest.php b/tests/Unit/RefSchemaTest.php new file mode 100644 index 0000000..ab88b94 --- /dev/null +++ b/tests/Unit/RefSchemaTest.php @@ -0,0 +1,30 @@ +ref('#/definitions/custom'); + + $schemaArray = $schema->toArray(); + + expect($schemaArray)->toHaveKey('$ref', '#/definitions/custom'); + expect($schemaArray)->toHaveKey('type', 'string'); +}); + +it('can create a schema with both $ref and other properties', function (): void { + $schema = Schema::string('name') + ->ref('#/definitions/custom') + ->description('A custom type') + ->nullable(); + + $schemaArray = $schema->toArray(); + + expect($schemaArray)->toHaveKey('$ref', '#/definitions/custom'); + expect($schemaArray)->toHaveKey('type', ['string', 'null']); + expect($schemaArray)->toHaveKey('description', 'A custom type'); +});