diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..6105737
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.yml,*.yaml,*.neon]
+indent_style = space
+indent_size = 2
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c8ec1b8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+* text=auto
+
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/.github export-ignore
+/phpunit.xml.dist export-ignore
+/README.md export-ignore
+/ecs.php export-ignore
+/tests export-ignore
+/phpstan.neon export-ignore
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..45efac9
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,19 @@
+version: 2
+updates:
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ labels:
+ - "dependencies"
+ - "composer"
+ versioning-strategy: "widen"
+ open-pull-requests-limit: 5
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ labels:
+ - "dependencies"
+ - "github-actions"
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000..fde4507
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,41 @@
+name: Tests
+
+on: [push]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: true
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ php: [8.3, 8.4]
+ stability: [prefer-lowest, prefer-stable]
+
+ name: PHP${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
+ coverage: none
+
+ - name: Setup problem matchers
+ run: |
+ echo "::add-matcher::${{ runner.tool_cache }}/php.json"
+ echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+
+ - name: Install dependencies
+ run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+
+ - name: Execute tests
+ run: vendor/bin/pest --colors=always
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
new file mode 100644
index 0000000..49e385f
--- /dev/null
+++ b/.github/workflows/static-analysis.yml
@@ -0,0 +1,47 @@
+name: Static Analysis
+
+on: [push]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ phpstan:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-interaction --no-progress
+
+ - name: Cache phpstan results
+ uses: actions/cache@v4
+ with:
+ path: .phpstan-cache
+ key: "result-cache-${{ github.run_id }}" # always write a new cache
+ restore-keys: |
+ result-cache-
+
+ - name: Run phpstan
+ run: vendor/bin/phpstan analyse -c phpstan.dist.neon --no-progress --error-format=github
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ac4ef68
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+vendor
+composer.lock
+.vscode
+.env
+.DS_Store
+.phpstan-cache
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bcf4651
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# json-schema
+
+A PHP library for fluently building and validating JSON Schemas.
+
+## Installation
+
+```bash
+composer require cortex/json-schema
+```
+
+## Usage
+
+```php
+$schema = ObjectSchema::make('user')
+ ->description('User schema')
+ ->properties(
+ StringSchema::make('name'),
+ StringSchema::make('email'),
+ );
+
+$schema->toArray();
+```
+
+```json
+{
+ "type": "object",
+ "title": "user",
+ "description": "User schema",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ }
+ }
+}
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..4a8edea
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,62 @@
+{
+ "name": "cortexphp/json-schema",
+ "description": "A fluent JSON Schema builder for PHP",
+ "keywords": [
+ "json",
+ "schema",
+ "cortex"
+ ],
+ "homepage": "https://github.com/cortexphp/json-schema",
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Sean Tymon",
+ "email": "tymon148@gmail.com",
+ "role": "Developer"
+ }
+ ],
+ "require": {
+ "php": "^8.3",
+ "opis/json-schema": "^2.3"
+ },
+ "require-dev": {
+ "pestphp/pest": "^3.0",
+ "phpstan/phpstan": "^2.0",
+ "rector/rector": "^2.0",
+ "symplify/easy-coding-standard": "^12.5"
+ },
+ "autoload": {
+ "psr-4": {
+ "Cortex\\JsonSchema\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Cortex\\JsonSchema\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "pest",
+ "ecs": "ecs check --fix",
+ "rector": "rector process",
+ "stan": "phpstan analyse",
+ "format": [
+ "@rector",
+ "@ecs"
+ ],
+ "check": [
+ "@format",
+ "@test",
+ "@stan"
+ ]
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
+}
diff --git a/ecs.php b/ecs.php
new file mode 100644
index 0000000..6ca69a9
--- /dev/null
+++ b/ecs.php
@@ -0,0 +1,94 @@
+withPaths([
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ ->withRootFiles()
+ ->withSpacing(indentation: Option::INDENTATION_SPACES)
+ ->withPreparedSets(
+ psr12: true,
+ common: true,
+ cleanCode: true,
+ strict: true,
+ )
+ ->withPhpCsFixerSets(
+ php83Migration: true,
+ )
+ ->withRules([
+ NotOperatorWithSuccessorSpaceFixer::class,
+ RemovePHPStormAnnotationFixer::class,
+ SingleLineCommentSpacingFixer::class,
+ NoTrailingWhitespaceInCommentFixer::class,
+ BlankLineBetweenImportGroupsFixer::class,
+ SingleLineEmptyBodyFixer::class,
+ ])
+ ->withConfiguredRule(FunctionDeclarationFixer::class, [
+ 'closure_fn_spacing' => FunctionDeclarationFixer::SPACING_NONE,
+ ])
+ ->withConfiguredRule(TrailingCommaInMultilineFixer::class, [
+ 'after_heredoc' => true,
+ 'elements' => [
+ TrailingCommaInMultilineFixer::ELEMENTS_ARGUMENTS,
+ TrailingCommaInMultilineFixer::ELEMENTS_ARRAYS,
+ TrailingCommaInMultilineFixer::ELEMENTS_PARAMETERS,
+ ],
+ ])
+ ->withConfiguredRule(OrderedImportsFixer::class, [
+ 'sort_algorithm' => OrderedImportsFixer::SORT_LENGTH,
+ 'imports_order' => [
+ OrderedImportsFixer::IMPORT_TYPE_CLASS,
+ OrderedImportsFixer::IMPORT_TYPE_FUNCTION,
+ OrderedImportsFixer::IMPORT_TYPE_CONST,
+ ],
+ ])
+ ->withConfiguredRule(ClassAttributesSeparationFixer::class, [
+ 'elements' => [
+ 'property' => ClassAttributesSeparationFixer::SPACING_ONE,
+ 'method' => ClassAttributesSeparationFixer::SPACING_ONE,
+ 'trait_import' => ClassAttributesSeparationFixer::SPACING_NONE,
+ ],
+ ])
+ ->withConfiguredRule(ClassDefinitionFixer::class, [
+ 'inline_constructor_arguments' => false,
+ 'space_before_parenthesis' => true,
+ ])
+ ->withConfiguredRule(PhpdocSeparationFixer::class, [
+ 'groups' => [
+ ['deprecated', 'link', 'see', 'since'],
+ ['author', 'copyright', 'license'],
+ ['category', 'package', 'subpackage'],
+ ['property', 'property-read', 'property-write'],
+ ['param'],
+ ['throws'],
+ ['return'],
+ ],
+ ])
+ ->withConfiguredRule(BlankLineBeforeStatementFixer::class, [
+ 'statements' => ['return', 'throw', 'if', 'switch', 'do', 'yield', 'try'],
+ ])
+ ->withSkip([
+ OrderedClassElementsFixer::class,
+ AssignmentInConditionSniff::class,
+ ]);
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
new file mode 100644
index 0000000..1f06893
--- /dev/null
+++ b/phpstan.dist.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: 8
+ paths:
+ - src
+ tmpDir: .phpstan-cache
diff --git a/phpunit.dist.xml b/phpunit.dist.xml
new file mode 100644
index 0000000..0c12bb9
--- /dev/null
+++ b/phpunit.dist.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ ./tests
+
+
+
+
+ ./src
+
+
+
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..626c42c
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,37 @@
+withPaths([
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ ->withParallel()
+ ->withImportNames(
+ importDocBlockNames: false,
+ removeUnusedImports: true,
+ )
+ ->withPhpSets()
+ ->withPreparedSets(
+ deadCode: true,
+ codeQuality: true,
+ codingStyle: true,
+ typeDeclarations: true,
+ instanceOf: true,
+ earlyReturn: true,
+ strictBooleans: true,
+ )
+ ->withFluentCallNewLine()
+ ->withSkip([
+ ClosureToArrowFunctionRector::class,
+ BooleanInIfConditionRuleFixerRector::class,
+ CatchExceptionNameMatchingTypeRector::class,
+ FlipTypeControlToUseExclusiveTypeRector::class,
+ ]);
diff --git a/src/Contracts/Schema.php b/src/Contracts/Schema.php
new file mode 100644
index 0000000..ccc5cf1
--- /dev/null
+++ b/src/Contracts/Schema.php
@@ -0,0 +1,66 @@
+
+ */
+ public function getType(): SchemaType|array;
+
+ /**
+ * Determine if the schema is required
+ */
+ public function isRequired(): bool;
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array;
+
+ /**
+ * Convert to JSON.
+ */
+ public function toJson(int $flags = 0): string;
+
+ /**
+ * Validate the given value against the schema.
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function validate(mixed $value): void;
+
+ /**
+ * Determine if the given value is valid against the schema.
+ */
+ public function isValid(mixed $value): bool;
+}
diff --git a/src/Enums/SchemaFormat.php b/src/Enums/SchemaFormat.php
new file mode 100644
index 0000000..2383421
--- /dev/null
+++ b/src/Enums/SchemaFormat.php
@@ -0,0 +1,27 @@
+ new StringSchema($title),
+ self::Number => new NumberSchema($title),
+ self::Integer => new IntegerSchema($title),
+ self::Boolean => new BooleanSchema($title),
+ self::Object => new ObjectSchema($title),
+ self::Array => new ArraySchema($title),
+ self::Null => new NullSchema($title),
+ };
+ }
+}
diff --git a/src/Exceptions/SchemaException.php b/src/Exceptions/SchemaException.php
new file mode 100644
index 0000000..abb8cc4
--- /dev/null
+++ b/src/Exceptions/SchemaException.php
@@ -0,0 +1,9 @@
+ $type
+ */
+ public function __construct(
+ protected SchemaType|array $type,
+ protected ?string $title = null,
+ ) {}
+
+ /**
+ * Get the type or types.
+ *
+ * @return \Cortex\JsonSchema\Enums\SchemaType|array
+ */
+ public function getType(): SchemaType|array
+ {
+ return $this->type;
+ }
+
+ /**
+ * Add null type to schema.
+ */
+ public function nullable(): static
+ {
+ if ($this->isNullable()) {
+ return $this;
+ }
+
+ if (is_array($this->type)) {
+ $this->type = [...$this->type, SchemaType::Null];
+ } else {
+ $this->type = SchemaType::Null;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Check if the schema allows null values.
+ */
+ protected function isNullable(): bool
+ {
+ return is_array($this->type) && in_array(SchemaType::Null, $this->type, true);
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array
+ {
+ $schema = [
+ 'type' => is_array($this->type)
+ ? array_map(fn(SchemaType $type) => $type->value, $this->type)
+ : $this->type->value,
+ ];
+
+ if ($includeSchemaRef) {
+ $schema['$schema'] = $this->schemaVersion;
+ }
+
+ $schema = $this->addTitleToSchema($schema, $includeTitle);
+ $schema = $this->addFormatToSchema($schema);
+ $schema = $this->addDescriptionToSchema($schema);
+
+ return $this->addReadWriteToSchema($schema);
+ }
+
+ /**
+ * Convert to JSON.
+ */
+ public function toJson(int $flags = 0): string
+ {
+ return (string) json_encode($this->toArray(), $flags);
+ }
+}
diff --git a/src/Types/ArraySchema.php b/src/Types/ArraySchema.php
new file mode 100644
index 0000000..f5eb48a
--- /dev/null
+++ b/src/Types/ArraySchema.php
@@ -0,0 +1,225 @@
+
+ */
+ protected array $prefixItems = [];
+
+ protected ?Schema $contains = null;
+
+ protected ?int $minContains = null;
+
+ protected ?int $maxContains = null;
+
+ public function __construct(?string $title = null)
+ {
+ parent::__construct(SchemaType::Array, $title);
+ }
+
+ /**
+ * Set the schema for validating array items.
+ */
+ public function items(Schema $schema): static
+ {
+ $this->items = $schema;
+
+ return $this;
+ }
+
+ /**
+ * Set the minimum number of items.
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function minItems(int $value): static
+ {
+ if ($value < 0) {
+ throw new SchemaException('minItems must be greater than or equal to 0');
+ }
+
+ $this->minItems = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set the maximum number of items.
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function maxItems(int $value): static
+ {
+ if ($value < 0) {
+ throw new SchemaException('maxItems must be greater than or equal to 0');
+ }
+
+ if ($this->minItems !== null && $value < $this->minItems) {
+ throw new SchemaException('maxItems must be greater than or equal to minItems');
+ }
+
+ $this->maxItems = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set whether items must be unique.
+ */
+ public function uniqueItems(bool $value = true): static
+ {
+ $this->uniqueItems = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set the prefix item schemas.
+ */
+ public function prefixItems(Schema ...$schemas): static
+ {
+ $this->prefixItems = array_values($schemas);
+
+ return $this;
+ }
+
+ /**
+ * Convert the schema to an array.
+ *
+ * @return array
+ */
+ #[Override]
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array
+ {
+ $schema = parent::toArray($includeSchemaRef, $includeTitle);
+
+ if ($this->items !== null) {
+ $schema['items'] = $this->items->toArray();
+ }
+
+ if ($this->minItems !== null) {
+ $schema['minItems'] = $this->minItems;
+ }
+
+ if ($this->maxItems !== null) {
+ $schema['maxItems'] = $this->maxItems;
+ }
+
+ if ($this->uniqueItems) {
+ $schema['uniqueItems'] = true;
+ }
+
+ if ($this->prefixItems !== []) {
+ $schema['prefixItems'] = array_map(
+ fn(Schema $schema): array => $schema->toArray(),
+ $this->prefixItems,
+ );
+ }
+
+ if ($this->contains !== null) {
+ $schema['contains'] = $this->contains->toArray();
+ }
+
+ if ($this->minContains !== null) {
+ $schema['minContains'] = $this->minContains;
+ }
+
+ if ($this->maxContains !== null) {
+ $schema['maxContains'] = $this->maxContains;
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Set the schema that array items must contain
+ */
+ public function contains(Schema $schema): static
+ {
+ $this->contains = $schema;
+
+ return $this;
+ }
+
+ /**
+ * Get the contains schema
+ */
+ public function getContains(): ?Schema
+ {
+ return $this->contains;
+ }
+
+ /**
+ * Set the minimum number of items that must match the contains schema
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function minContains(int $min): static
+ {
+ if ($min < 0) {
+ throw new SchemaException('minContains must be non-negative');
+ }
+
+ if ($this->maxContains !== null && $min > $this->maxContains) {
+ throw new SchemaException('minContains cannot be greater than maxContains');
+ }
+
+ $this->minContains = $min;
+
+ return $this;
+ }
+
+ /**
+ * Get the minimum number of items that must match the contains schema
+ */
+ public function getMinContains(): ?int
+ {
+ return $this->minContains;
+ }
+
+ /**
+ * Set the maximum number of items that can match the contains schema
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function maxContains(int $max): static
+ {
+ if ($max < 0) {
+ throw new SchemaException('maxContains must be non-negative');
+ }
+
+ if ($this->minContains !== null && $max < $this->minContains) {
+ throw new SchemaException('maxContains cannot be less than minContains');
+ }
+
+ $this->maxContains = $max;
+
+ return $this;
+ }
+
+ /**
+ * Get the maximum number of items that can match the contains schema
+ */
+ public function getMaxContains(): ?int
+ {
+ return $this->maxContains;
+ }
+}
diff --git a/src/Types/BooleanSchema.php b/src/Types/BooleanSchema.php
new file mode 100644
index 0000000..d612698
--- /dev/null
+++ b/src/Types/BooleanSchema.php
@@ -0,0 +1,15 @@
+description = $description;
+
+ return $this;
+ }
+
+ /**
+ * Get the description
+ */
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ /**
+ * Add description field to schema array
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addDescriptionToSchema(array $schema): array
+ {
+ if ($this->description !== null) {
+ $schema['description'] = $this->description;
+ }
+
+ return $schema;
+ }
+}
diff --git a/src/Types/Concerns/HasFormat.php b/src/Types/Concerns/HasFormat.php
new file mode 100644
index 0000000..2302969
--- /dev/null
+++ b/src/Types/Concerns/HasFormat.php
@@ -0,0 +1,40 @@
+format = $format;
+
+ return $this;
+ }
+
+ /**
+ * Add format field to schema array
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addFormatToSchema(array $schema): array
+ {
+ if ($this->format !== null) {
+ $schema['format'] = $this->format instanceof SchemaFormat
+ ? $this->format->value
+ : $this->format;
+ }
+
+ return $schema;
+ }
+}
diff --git a/src/Types/Concerns/HasReadWrite.php b/src/Types/Concerns/HasReadWrite.php
new file mode 100644
index 0000000..695eba7
--- /dev/null
+++ b/src/Types/Concerns/HasReadWrite.php
@@ -0,0 +1,52 @@
+readOnly = $readOnly;
+
+ return $this;
+ }
+
+ /**
+ * Mark as write-only
+ */
+ public function writeOnly(bool $writeOnly = true): static
+ {
+ $this->writeOnly = $writeOnly;
+
+ return $this;
+ }
+
+ /**
+ * Add read/write fields to schema array
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addReadWriteToSchema(array $schema): array
+ {
+ if ($this->readOnly !== null) {
+ $schema['readOnly'] = $this->readOnly;
+ }
+
+ if ($this->writeOnly !== null) {
+ $schema['writeOnly'] = $this->writeOnly;
+ }
+
+ return $schema;
+ }
+}
diff --git a/src/Types/Concerns/HasRequired.php b/src/Types/Concerns/HasRequired.php
new file mode 100644
index 0000000..1d23f1d
--- /dev/null
+++ b/src/Types/Concerns/HasRequired.php
@@ -0,0 +1,29 @@
+required = true;
+
+ return $this;
+ }
+
+ /**
+ * Determine if the schema is required
+ */
+ public function isRequired(): bool
+ {
+ return $this->required;
+ }
+}
diff --git a/src/Types/Concerns/HasTitle.php b/src/Types/Concerns/HasTitle.php
new file mode 100644
index 0000000..8ccfc23
--- /dev/null
+++ b/src/Types/Concerns/HasTitle.php
@@ -0,0 +1,45 @@
+title = $title;
+
+ return $this;
+ }
+
+ /**
+ * Get the title
+ */
+ public function getTitle(): ?string
+ {
+ return $this->title;
+ }
+
+ /**
+ * Add title to schema array
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addTitleToSchema(array $schema, bool $includeTitle = true): array
+ {
+ if ($this->title !== null && $includeTitle) {
+ $schema['title'] = $this->title;
+ }
+
+ return $schema;
+ }
+}
diff --git a/src/Types/Concerns/HasValidation.php b/src/Types/Concerns/HasValidation.php
new file mode 100644
index 0000000..0f71932
--- /dev/null
+++ b/src/Types/Concerns/HasValidation.php
@@ -0,0 +1,57 @@
+validate(
+ Helper::toJSON($value),
+ Helper::toJSON($this->toArray()),
+ );
+ } catch (OpisSchemaException|InvalidArgumentException $e) {
+ throw new SchemaException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ $error = $result->error();
+
+ if ($error !== null) {
+ throw new SchemaException(
+ (new ErrorFormatter())->formatErrorMessage($error),
+ );
+ }
+ }
+
+ /**
+ * Determine if the given value is valid against the schema.
+ */
+ public function isValid(mixed $value): bool
+ {
+ try {
+ $this->validate($value);
+
+ return true;
+ } catch (SchemaException) {
+ return false;
+ }
+ }
+}
diff --git a/src/Types/IntegerSchema.php b/src/Types/IntegerSchema.php
new file mode 100644
index 0000000..b7f6939
--- /dev/null
+++ b/src/Types/IntegerSchema.php
@@ -0,0 +1,16 @@
+type = SchemaType::Integer;
+ }
+}
diff --git a/src/Types/NullSchema.php b/src/Types/NullSchema.php
new file mode 100644
index 0000000..140ff10
--- /dev/null
+++ b/src/Types/NullSchema.php
@@ -0,0 +1,15 @@
+minimum = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set the maximum value (inclusive).
+ */
+ public function maximum(float $value): static
+ {
+ $this->maximum = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set the exclusive minimum value.
+ */
+ public function exclusiveMinimum(float $value): static
+ {
+ $this->exclusiveMinimum = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set the exclusive maximum value.
+ */
+ public function exclusiveMaximum(float $value): static
+ {
+ $this->exclusiveMaximum = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set the multipleOf value.
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function multipleOf(float $value): static
+ {
+ if ($value <= 0) {
+ throw new SchemaException('multipleOf must be greater than 0');
+ }
+
+ $this->multipleOf = $value;
+
+ return $this;
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ #[Override]
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array
+ {
+ $schema = parent::toArray($includeSchemaRef, $includeTitle);
+
+ if ($this->minimum !== null) {
+ $schema['minimum'] = $this->minimum;
+ }
+
+ if ($this->maximum !== null) {
+ $schema['maximum'] = $this->maximum;
+ }
+
+ if ($this->exclusiveMinimum !== null) {
+ $schema['exclusiveMinimum'] = $this->exclusiveMinimum;
+ }
+
+ if ($this->exclusiveMaximum !== null) {
+ $schema['exclusiveMaximum'] = $this->exclusiveMaximum;
+ }
+
+ if ($this->multipleOf !== null) {
+ $schema['multipleOf'] = $this->multipleOf;
+ }
+
+ return $schema;
+ }
+}
diff --git a/src/Types/ObjectSchema.php b/src/Types/ObjectSchema.php
new file mode 100644
index 0000000..3efdafb
--- /dev/null
+++ b/src/Types/ObjectSchema.php
@@ -0,0 +1,296 @@
+
+ */
+ protected array $properties = [];
+
+ /**
+ * @var array
+ */
+ public array $requiredProperties = [];
+
+ protected ?bool $additionalProperties = null;
+
+ protected bool|Schema|null $unevaluatedProperties = null;
+
+ protected ?int $minProperties = null;
+
+ protected ?int $maxProperties = null;
+
+ protected ?Schema $propertyNames = null;
+
+ /**
+ * @var array>
+ */
+ protected array $dependentRequired = [];
+
+ /**
+ * @var array
+ */
+ protected array $dependentSchemas = [];
+
+ public function __construct(?string $title = null)
+ {
+ parent::__construct(SchemaType::Object, $title);
+ }
+
+ /**
+ * Set properties.
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function properties(Schema ...$properties): static
+ {
+ foreach ($properties as $property) {
+ $title = $property->getTitle();
+
+ if ($title === null) {
+ throw new SchemaException('Property must have a title');
+ }
+
+ $this->properties[$title] = $property;
+
+ if ($property->isRequired()) {
+ $this->requiredProperties[] = $title;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allow or disallow additional properties
+ */
+ public function additionalProperties(bool $allowed): static
+ {
+ $this->additionalProperties = $allowed;
+
+ return $this;
+ }
+
+ /**
+ * Get the property keys
+ *
+ * @return array
+ */
+ public function getPropertyKeys(): array
+ {
+ return array_keys($this->properties);
+ }
+
+ /**
+ * Set whether unevaluated properties are allowed and optionally their schema.
+ */
+ public function unevaluatedProperties(bool|Schema $value): static
+ {
+ $this->unevaluatedProperties = $value;
+
+ return $this;
+ }
+
+ /**
+ * Get the unevaluated properties setting
+ */
+ public function getUnevaluatedProperties(): bool|Schema|null
+ {
+ return $this->unevaluatedProperties;
+ }
+
+ /**
+ * Set the minimum number of properties
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function minProperties(int $min): static
+ {
+ if ($min < 0) {
+ throw new SchemaException('minProperties must be non-negative');
+ }
+
+ if ($this->maxProperties !== null && $min > $this->maxProperties) {
+ throw new SchemaException('minProperties cannot be greater than maxProperties');
+ }
+
+ $this->minProperties = $min;
+
+ return $this;
+ }
+
+ /**
+ * Get the minimum number of properties
+ */
+ public function getMinProperties(): ?int
+ {
+ return $this->minProperties;
+ }
+
+ /**
+ * Set the maximum number of properties
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function maxProperties(int $max): static
+ {
+ if ($max < 0) {
+ throw new SchemaException('maxProperties must be non-negative');
+ }
+
+ if ($this->minProperties !== null && $max < $this->minProperties) {
+ throw new SchemaException('maxProperties cannot be less than minProperties');
+ }
+
+ $this->maxProperties = $max;
+
+ return $this;
+ }
+
+ /**
+ * Get the maximum number of properties
+ */
+ public function getMaxProperties(): ?int
+ {
+ return $this->maxProperties;
+ }
+
+ /**
+ * Set the schema for property names
+ */
+ public function propertyNames(Schema $schema): static
+ {
+ $this->propertyNames = $schema;
+
+ return $this;
+ }
+
+ /**
+ * Get the property names schema
+ */
+ public function getPropertyNames(): ?Schema
+ {
+ return $this->propertyNames;
+ }
+
+ /**
+ * Set properties that are required when a property is present
+ *
+ * @param array $requiredProperties
+ */
+ public function dependentRequired(string $property, array $requiredProperties): static
+ {
+ $this->dependentRequired[$property] = $requiredProperties;
+
+ return $this;
+ }
+
+ /**
+ * Get the dependent required properties
+ *
+ * @return array>
+ */
+ public function getDependentRequired(): array
+ {
+ return $this->dependentRequired;
+ }
+
+ /**
+ * Set a schema that must be valid when a property is present
+ */
+ public function dependentSchema(string $property, Schema $schema): static
+ {
+ $this->dependentSchemas[$property] = $schema;
+
+ return $this;
+ }
+
+ /**
+ * Get the dependent schemas
+ *
+ * @return array
+ */
+ public function getDependentSchemas(): array
+ {
+ return $this->dependentSchemas;
+ }
+
+ /**
+ * Add properties to schema array
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addPropertiesToSchema(array $schema): array
+ {
+ if ($this->properties !== []) {
+ $schema['properties'] = [];
+
+ foreach ($this->properties as $name => $prop) {
+ $schema['properties'][$name] = $prop->toArray(includeSchemaRef: false, includeTitle: false);
+ }
+ }
+
+ if ($this->requiredProperties !== []) {
+ $schema['required'] = array_values(array_unique($this->requiredProperties));
+ }
+
+ if ($this->additionalProperties !== null) {
+ $schema['additionalProperties'] = $this->additionalProperties;
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ #[Override]
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array
+ {
+ $schema = parent::toArray($includeSchemaRef, $includeTitle);
+
+ if ($this->propertyNames !== null) {
+ $schema['propertyNames'] = $this->propertyNames->toArray($includeSchemaRef, $includeTitle);
+ }
+
+ if ($this->dependentRequired !== []) {
+ $schema['dependentRequired'] = $this->dependentRequired;
+ }
+
+ if ($this->dependentSchemas !== []) {
+ $schema['dependentSchemas'] = array_map(
+ fn(Schema $schema): array => $schema->toArray($includeSchemaRef, $includeTitle),
+ $this->dependentSchemas,
+ );
+ }
+
+ if ($this->unevaluatedProperties !== null) {
+ $schema['unevaluatedProperties'] = $this->unevaluatedProperties instanceof Schema
+ ? $this->unevaluatedProperties->toArray($includeSchemaRef, $includeTitle)
+ : $this->unevaluatedProperties;
+ }
+
+ if ($this->minProperties !== null) {
+ $schema['minProperties'] = $this->minProperties;
+ }
+
+ if ($this->maxProperties !== null) {
+ $schema['maxProperties'] = $this->maxProperties;
+ }
+
+ return $this->addPropertiesToSchema($schema);
+ }
+}
diff --git a/src/Types/StringSchema.php b/src/Types/StringSchema.php
new file mode 100644
index 0000000..eb250ee
--- /dev/null
+++ b/src/Types/StringSchema.php
@@ -0,0 +1,104 @@
+minLength = $length;
+
+ return $this;
+ }
+
+ /**
+ * Set the maximum length.
+ *
+ * @throws \Cortex\JsonSchema\Exceptions\SchemaException
+ */
+ public function maxLength(int $length): static
+ {
+ if ($length < 0) {
+ throw new SchemaException('Maximum length must be non-negative');
+ }
+
+ if ($this->minLength !== null && $length < $this->minLength) {
+ throw new SchemaException('Maximum length must be greater than or equal to minimum length');
+ }
+
+ $this->maxLength = $length;
+
+ return $this;
+ }
+
+ /**
+ * Set the pattern.
+ */
+ public function pattern(string $pattern): static
+ {
+ $this->pattern = $pattern;
+
+ return $this;
+ }
+
+ /**
+ * Add length constraints to schema array.
+ *
+ * @param array $schema
+ *
+ * @return array
+ */
+ protected function addLengthToSchema(array $schema): array
+ {
+ if ($this->minLength !== null) {
+ $schema['minLength'] = $this->minLength;
+ }
+
+ if ($this->maxLength !== null) {
+ $schema['maxLength'] = $this->maxLength;
+ }
+
+ if ($this->pattern !== null) {
+ $schema['pattern'] = $this->pattern;
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Convert to array.
+ *
+ * @return array
+ */
+ #[Override]
+ public function toArray(bool $includeSchemaRef = true, bool $includeTitle = true): array
+ {
+ $schema = parent::toArray($includeSchemaRef, $includeTitle);
+
+ return $this->addLengthToSchema($schema);
+ }
+}
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..42e38fa
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,7 @@
+in('Unit');
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..a70fb37
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,12 @@
+description('User schema')
+ ->properties(
+ Schema::string('name'),
+ Schema::string('email')
+ ->format(SchemaFormat::Email),
+ Schema::string('dob')
+ ->format(SchemaFormat::Date),
+ Schema::integer('age')
+ ->minimum(18)
+ ->maximum(150)
+ ->readOnly(),
+ Schema::boolean('is_active'),
+ Schema::string('deleted_at')
+ ->format(SchemaFormat::DateTime)
+ ->nullable(),
+ );
+
+ var_dump($schema->toArray());
+
+ expect($schema->toArray())->toHaveKey('description', 'User schema');
+});