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'); +});