From 4668680cc6e404d7e3e5bd71e4e69fcd83d2a862 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Mon, 13 Jan 2025 00:38:07 +0000 Subject: [PATCH] Initial commit --- .editorconfig | 16 ++ .gitattributes | 11 + .github/dependabot.yml | 19 ++ .github/workflows/run-tests.yml | 41 ++++ .github/workflows/static-analysis.yml | 47 ++++ .gitignore | 6 + README.md | 38 ++++ composer.json | 62 ++++++ ecs.php | 94 ++++++++ phpstan.dist.neon | 5 + phpunit.dist.xml | 17 ++ rector.php | 37 ++++ src/Contracts/Schema.php | 66 ++++++ src/Enums/SchemaFormat.php | 27 +++ src/Enums/SchemaType.php | 41 ++++ src/Exceptions/SchemaException.php | 9 + src/SchemaFactory.php | 51 +++++ src/Types/AbstractSchema.php | 102 +++++++++ src/Types/ArraySchema.php | 225 ++++++++++++++++++++ src/Types/BooleanSchema.php | 15 ++ src/Types/Concerns/HasDescription.php | 45 ++++ src/Types/Concerns/HasFormat.php | 40 ++++ src/Types/Concerns/HasReadWrite.php | 52 +++++ src/Types/Concerns/HasRequired.php | 29 +++ src/Types/Concerns/HasTitle.php | 45 ++++ src/Types/Concerns/HasValidation.php | 57 +++++ src/Types/IntegerSchema.php | 16 ++ src/Types/NullSchema.php | 15 ++ src/Types/NumberSchema.php | 116 ++++++++++ src/Types/ObjectSchema.php | 296 ++++++++++++++++++++++++++ src/Types/StringSchema.php | 104 +++++++++ tests/Pest.php | 7 + tests/TestCase.php | 12 ++ tests/Unit/ObjectSchemaTest.php | 32 +++ 34 files changed, 1795 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/run-tests.yml create mode 100644 .github/workflows/static-analysis.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 ecs.php create mode 100644 phpstan.dist.neon create mode 100644 phpunit.dist.xml create mode 100644 rector.php create mode 100644 src/Contracts/Schema.php create mode 100644 src/Enums/SchemaFormat.php create mode 100644 src/Enums/SchemaType.php create mode 100644 src/Exceptions/SchemaException.php create mode 100644 src/SchemaFactory.php create mode 100644 src/Types/AbstractSchema.php create mode 100644 src/Types/ArraySchema.php create mode 100644 src/Types/BooleanSchema.php create mode 100644 src/Types/Concerns/HasDescription.php create mode 100644 src/Types/Concerns/HasFormat.php create mode 100644 src/Types/Concerns/HasReadWrite.php create mode 100644 src/Types/Concerns/HasRequired.php create mode 100644 src/Types/Concerns/HasTitle.php create mode 100644 src/Types/Concerns/HasValidation.php create mode 100644 src/Types/IntegerSchema.php create mode 100644 src/Types/NullSchema.php create mode 100644 src/Types/NumberSchema.php create mode 100644 src/Types/ObjectSchema.php create mode 100644 src/Types/StringSchema.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ObjectSchemaTest.php 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'); +});