refactor(schemas): use standardized schema shapes (#33)

* refactor(schemas): use standardized schema shapes

* refactor: apply rector

* chore: fix types

* test(schemas): add missing tests

* chore(schemas): drop null schema

* feat(schemas): cover json rule
This commit is contained in:
Mazen Touati
2026-01-12 21:23:04 +01:00
committed by GitHub
parent 8780a79557
commit 78d91a39c1
61 changed files with 1618 additions and 1074 deletions

View File

@@ -7,14 +7,12 @@ use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute;
/**
* @phpstan-import-type SchemaShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema
*
* @phpstan-type RouteDefinitionShape array{
* uri: string,
* shortUri: string,
* methods: string[],
* schema: SchemaShape,
* extractionError: string,
* schema: array<string, mixed>,
* extractionError: string|null,
* }
*
* @extends Collection<array-key, ExtractedRoute>

View File

@@ -2,8 +2,17 @@
namespace Sunchayn\Nimbus\Modules\Schemas\Builders;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
use Sunchayn\Nimbus\Modules\Schemas\Enums\StringFormat;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\RuleToSchemaMapper;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\ArraySchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\BooleanSchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\IntegerSchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\NumberSchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\ObjectSchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\StringSchemaProperty;
/**
* Converts Laravel validation rules into individual schema properties.
@@ -16,7 +25,6 @@ use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
* Output: SchemaProperty with type="string", format="email", required=true
*
* @phpstan-import-type NormalizedRulesShape from \Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset
* @phpstan-import-type SchemaPropertyFormatsShape from SchemaProperty
*/
class PropertyBuilder
{
@@ -27,26 +35,53 @@ class PropertyBuilder
/**
* @param NormalizedRulesShape $rules
*/
public function buildPropertyFromRules(string $field, array $rules): SchemaProperty
public function buildPropertyFromRules(string $field, array $rules): SchemaPropertyInterface
{
$schemaMetadata = $this->ruleToSchemaMapper->convertRulesToBaseSchemaPropertyMetadata($rules);
return new SchemaProperty(
name: $field,
type: $schemaMetadata['type'],
required: $schemaMetadata['required'],
format: $this->extractFormat($rules),
enum: $schemaMetadata['enum'] ?? null,
minimum: $schemaMetadata['minimum'],
maximum: $schemaMetadata['maximum'],
);
return match ($schemaMetadata['type']) {
SchemaPropertyType::STRING => new StringSchemaProperty(
name: $field,
required: $schemaMetadata['required'],
stringFormat: $this->extractFormat($rules),
enum: $schemaMetadata['enum'] ?? null,
minLength: $schemaMetadata['minimum'],
maxLength: $schemaMetadata['maximum'],
),
SchemaPropertyType::INTEGER => new IntegerSchemaProperty(
name: $field,
required: $schemaMetadata['required'],
minimum: $schemaMetadata['minimum'],
maximum: $schemaMetadata['maximum'],
enum: $schemaMetadata['enum'] ?? null,
),
SchemaPropertyType::NUMBER => new NumberSchemaProperty(
name: $field,
required: $schemaMetadata['required'],
minimum: $schemaMetadata['minimum'],
maximum: $schemaMetadata['maximum'],
),
SchemaPropertyType::BOOLEAN => new BooleanSchemaProperty(
name: $field,
required: $schemaMetadata['required'],
),
SchemaPropertyType::ARRAY => new ArraySchemaProperty(
name: $field,
required: $schemaMetadata['required'],
schemaProperty: null, // <- Items will be set later by SchemaBuilder if needed.
),
SchemaPropertyType::OBJECT => new ObjectSchemaProperty(
name: $field,
required: $schemaMetadata['required'],
schema: new Schema([]), // <- Properties will be set later by SchemaBuilder if needed.
),
};
}
/**
* @param NormalizedRulesShape $rules
* @return SchemaPropertyFormatsShape|null
*/
private function extractFormat(array $rules): ?string
private function extractFormat(array $rules): ?StringFormat
{
foreach ($rules as $rule) {
if (! is_string($rule)) {
@@ -55,7 +90,7 @@ class PropertyBuilder
$format = $this->detectFormatFromRule($rule);
if ($format !== null) {
if ($format instanceof \Sunchayn\Nimbus\Modules\Schemas\Enums\StringFormat) {
return $format;
}
}
@@ -63,16 +98,8 @@ class PropertyBuilder
return null;
}
/**
* @return SchemaPropertyFormatsShape|null
*/
private function detectFormatFromRule(string $rule): ?string
private function detectFormatFromRule(string $rule): ?StringFormat
{
return match ($rule) {
'email' => 'email',
'uuid' => 'uuid',
'date' => 'date-time',
default => null,
};
return StringFormat::fromRule($rule);
}
}

View File

@@ -7,11 +7,13 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\RulesFieldType;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\ArraySchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\FieldPath;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\ObjectSchemaProperty;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\PathSegment;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
/**
* Converts Laravel validation rules into JSON Schema structures.
@@ -45,7 +47,7 @@ class SchemaBuilder
}
/**
* @return array<string, SchemaProperty>
* @return array<string, SchemaPropertyInterface>
*/
private function buildProperties(Ruleset $ruleset): array
{
@@ -96,9 +98,9 @@ class SchemaBuilder
/**
* Adds a simple array property (e.g., "tags.*" => "string").
*
* @param array<string, SchemaProperty> $properties
* @param array<string, SchemaPropertyInterface> $properties
* @param NormalizedRulesShape $rules
* @return array<string, SchemaProperty>
* @return array<string, SchemaPropertyInterface>
*/
private function addSimpleArrayProperty(FieldPath $fieldPath, array $rules, array $properties): array
{
@@ -116,11 +118,10 @@ class SchemaBuilder
rules: $rules,
);
$properties[$arrayName] = new SchemaProperty(
$properties[$arrayName] = new ArraySchemaProperty(
name: $arrayName,
type: 'array',
required: $existingProperty->required ?? false,
itemsSchema: $schemaProperty,
required: $existingProperty?->isRequired() ?? false,
schemaProperty: $schemaProperty,
);
return $properties;
@@ -134,9 +135,9 @@ class SchemaBuilder
* - "users.*.email" → array of objects with email property
* - "company.teams.*.members.*.name" → deeply nested arrays
*
* @param array<string, SchemaProperty> $properties
* @param array<string, SchemaPropertyInterface> $properties
* @param NormalizedRulesShape $rules
* @return array<string, SchemaProperty>
* @return array<string, SchemaPropertyInterface>
*/
private function addDotNotationStructure(FieldPath $fieldPath, array $rules, array $properties): array
{
@@ -190,10 +191,10 @@ class SchemaBuilder
* @param PathSegment[] $segments
*/
private function buildNestedStructure(
SchemaProperty $schemaProperty,
SchemaPropertyInterface $schemaProperty,
array $segments,
array $rules
): SchemaProperty {
): SchemaPropertyInterface {
if ($segments === []) {
return $schemaProperty;
}
@@ -214,20 +215,22 @@ class SchemaBuilder
* @param PathSegment[] $segments
*/
private function convertPropertyToArray(
SchemaProperty $schemaProperty,
SchemaPropertyInterface $schemaProperty,
array $segments,
array $rules
): SchemaProperty {
$itemObject = $schemaProperty->itemsSchema ?? $this->createEmptyObject(name: 'item');
): SchemaPropertyInterface {
// Get existing item schema if this is already an array, otherwise create new empty object
$itemObject = ($schemaProperty instanceof ArraySchemaProperty)
? $schemaProperty->getItemsSchema() ?? $this->createEmptyObject(name: 'item')
: $this->createEmptyObject(name: 'item');
// Build the item structure from remaining segments.
$itemSchema = $this->buildNestedStructure($itemObject, $segments, $rules);
return new SchemaProperty(
name: $schemaProperty->name,
type: 'array',
required: $schemaProperty->required,
itemsSchema: $itemSchema,
return new ArraySchemaProperty(
name: $schemaProperty->getName(),
required: $schemaProperty->isRequired(),
schemaProperty: $itemSchema,
);
}
@@ -238,25 +241,28 @@ class SchemaBuilder
* @param PathSegment[] $remainingSegments
*/
private function addPropertyToStructure(
SchemaProperty $schemaProperty,
SchemaPropertyInterface $schemaProperty,
PathSegment $pathSegment,
array $remainingSegments,
array $rules
): SchemaProperty {
): SchemaPropertyInterface {
$propertyName = $pathSegment->value;
$existingSchema = $schemaProperty->propertiesSchema ?? new Schema([]);
// Get existing schema from object property
$existingSchema = ($schemaProperty instanceof ObjectSchemaProperty)
? $schemaProperty->getPropertiesSchema() ?? new Schema([])
: new Schema([]);
/** @var Collection<string, SchemaProperty> $properties */
$properties = Collection::make($existingSchema->properties)->keyBy('name');
/** @var Collection<string, SchemaPropertyInterface> $properties */
$properties = Collection::make($existingSchema->properties)->keyBy(fn ($p): string => $p->getName());
// If this is a leaf, build the final property with rules.
if ($pathSegment->isLeaf) {
$newProperty = $this->propertyBuilder->buildPropertyFromRules($propertyName, $rules);
$properties->put($newProperty->name, $newProperty);
$properties->put($newProperty->getName(), $newProperty);
return $this->rebuildObjectPropertyWithNewSchema($schemaProperty, $properties->all());
return $this->rebuildObjectPropertyWithNewSchema($schemaProperty, $properties->values()->all());
}
// Otherwise, create/get intermediate object and recurse.
@@ -265,40 +271,36 @@ class SchemaBuilder
$updatedProperty = $this->buildNestedStructure($intermediateProperty, $remainingSegments, $rules);
$properties->put($updatedProperty->name, $updatedProperty);
$properties->put($updatedProperty->getName(), $updatedProperty);
return $this->rebuildObjectPropertyWithNewSchema($schemaProperty, $properties->all());
return $this->rebuildObjectPropertyWithNewSchema($schemaProperty, $properties->values()->all());
}
/**
* Rebuilds a property with updated child properties.
*
* @param SchemaProperty[] $properties
* @param SchemaPropertyInterface[] $properties
*/
private function rebuildObjectPropertyWithNewSchema(
SchemaProperty $schemaProperty,
SchemaPropertyInterface $schemaProperty,
array $properties
): SchemaProperty {
return new SchemaProperty(
name: $schemaProperty->name,
type: 'object',
required: $schemaProperty->required,
format: $schemaProperty->format,
enum: $schemaProperty->enum,
propertiesSchema: new Schema($properties),
): SchemaPropertyInterface {
return new ObjectSchemaProperty(
name: $schemaProperty->getName(),
required: $schemaProperty->isRequired(),
schema: new Schema($properties),
);
}
/**
* Creates an empty object property.
*/
private function createEmptyObject(string $name): SchemaProperty
private function createEmptyObject(string $name): SchemaPropertyInterface
{
return new SchemaProperty(
return new ObjectSchemaProperty(
name: $name,
type: 'object',
required: false,
propertiesSchema: new Schema([])
schema: new Schema([])
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Contracts;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
/**
* Interface for all schema property types.
*
* Defines the contract that all schema properties must implement,
* ensuring consistent serialization to both internal format (with custom properties)
* and standard JSON Schema format.
*/
interface SchemaPropertyInterface
{
/**
* Get the property name.
*/
public function getName(): string;
/**
* Check if the property is required.
*/
public function isRequired(): bool;
/**
* Get the JSON Schema type.
*/
public function getType(): SchemaPropertyType;
/**
* Convert to standard JSON Schema format.
*
* This produces a pure JSON Schema Draft 2020-12 compliant structure
* without custom extensions.
*
* @return array<string, mixed>
*/
public function toJsonSchema(): array;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Enums;
/**
* JSON Schema property types.
*
* Represents the standard JSON Schema primitive and complex types.
*
* @see https://json-schema.org/understanding-json-schema/reference/type
*/
enum SchemaPropertyType: string
{
case STRING = 'string';
case INTEGER = 'integer';
case NUMBER = 'number';
case BOOLEAN = 'boolean';
case ARRAY = 'array';
case OBJECT = 'object';
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Enums;
/**
* Supported JSON Schema string formats.
*
* @see https://json-schema.org/draft/2020-12/json-schema-validation#section-7.3
*/
enum StringFormat: string
{
case UUID = 'uuid';
case EMAIL = 'email';
case DATE_TIME = 'date-time';
case URL = 'url';
case URI = 'uri';
case DATE = 'date';
case TIME = 'time';
/**
* Map common validation rule names to their corresponding format.
*/
public static function fromRule(string $rule): ?self
{
return match ($rule) {
'uuid' => self::UUID,
'email' => self::EMAIL,
'date' => self::DATE_TIME, // Laravel's 'date' rule often maps to date-time in schema context
'url' => self::URL,
default => null,
};
}
}

View File

@@ -4,17 +4,16 @@ namespace Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors;
use BackedEnum;
use Illuminate\Validation\Rules\Enum;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
use UnitEnum;
/**
* Processes `Enum` validation rules to extract enum values for schema generation.
*
* @phpstan-import-type SchemaPropertyEnumShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
*/
class EnumRuleProcessor
{
/**
* @return array{type: 'string', enum: SchemaPropertyEnumShape|null}
* @return array{type: SchemaPropertyType, enum: ?non-empty-array<array-key, scalar>}
*/
public static function process(Enum $rule): array
{
@@ -22,7 +21,7 @@ class EnumRuleProcessor
$enumClass = invade($rule)->type; // @phpstan-ignore-line
if (! enum_exists($enumClass)) {
return ['type' => 'string', 'enum' => null];
return ['type' => SchemaPropertyType::STRING, 'enum' => null];
}
$values = array_map(
@@ -31,9 +30,9 @@ class EnumRuleProcessor
);
if ($values === []) {
return ['type' => 'string', 'enum' => null];
return ['type' => SchemaPropertyType::STRING, 'enum' => null];
}
return ['type' => 'string', 'enum' => $values];
return ['type' => SchemaPropertyType::STRING, 'enum' => $values];
}
}

View File

@@ -4,18 +4,16 @@ namespace Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors;
use BackedEnum;
use Illuminate\Validation\Rules\In;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
use UnitEnum;
/**
* Processes `In` validation rules to extract allowed values for schema generation.
*
* @phpstan-import-type SchemaPropertyTypesShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
* @phpstan-import-type SchemaPropertyEnumShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
*/
class InRuleProcessor
{
/**
* @return array{type: 'string'|'integer', enum: SchemaPropertyEnumShape|null}
* @return array{type: SchemaPropertyType::STRING | SchemaPropertyType::INTEGER, enum: ?non-empty-array<array-key, scalar>}
*/
public static function process(In $in): array
{
@@ -33,20 +31,20 @@ class InRuleProcessor
$rawValues,
);
/** @var array<array-key, scalar> $values */
/** @var array<array-key, scalar>|array{} $values */
$values = array_values(
array_filter($values), // <- Removes null values.
);
if (empty($values)) {
return ['type' => 'string', 'enum' => null];
return ['type' => SchemaPropertyType::STRING, 'enum' => null];
}
$identityValue = $values[0];
$type = match (true) {
is_int($identityValue) => 'integer',
default => 'string',
is_int($identityValue) => SchemaPropertyType::INTEGER,
default => SchemaPropertyType::STRING,
};
return ['type' => $type, 'enum' => $values];

View File

@@ -5,6 +5,7 @@ namespace Sunchayn\Nimbus\Modules\Schemas\RulesMapper;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationRuleParser;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\EnumRuleProcessor;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\InRuleProcessor;
@@ -12,24 +13,18 @@ use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\InRuleProcessor;
* Converts Laravel validation rules into JSON Schema property definitions.
*
* @phpstan-import-type NormalizedRulesShape from \Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset
* @phpstan-import-type SchemaPropertyTypesShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
* @phpstan-import-type SchemaPropertyFormatsShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
* @phpstan-import-type SchemaPropertyEnumShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
*/
class RuleToSchemaMapper
{
/**
* Converts an array of Laravel validation rules into schema property data.
*
* Laravel's validation rules are processed sequentially, with later rules
* potentially overriding earlier ones (e.g., 'string' then 'email').
*
* @param NormalizedRulesShape $rules
* @return array{
* type: SchemaPropertyTypesShape,
* type: SchemaPropertyType,
* required: bool,
* format: SchemaPropertyFormatsShape|null,
* enum: SchemaPropertyEnumShape|null,
* format: string|null,
* enum: ?non-empty-array<array-key, scalar>,
* minimum: ?int,
* maximum: ?int,
* }
@@ -37,7 +32,7 @@ class RuleToSchemaMapper
public function convertRulesToBaseSchemaPropertyMetadata(array $rules): array
{
$shape = [
'type' => 'string',
'type' => SchemaPropertyType::STRING,
'required' => false,
'format' => null,
'enum' => null,
@@ -58,13 +53,14 @@ class RuleToSchemaMapper
/**
* Processes individual validation rules and returns the changes to apply.
*
* @return array{}|array{
* type?: SchemaPropertyTypesShape,
* format?: SchemaPropertyFormatsShape,
* enum?: SchemaPropertyEnumShape|null,
* @return array{
* type?: SchemaPropertyType,
* format?: string,
* enum?: ?non-empty-array<array-key, scalar>,
* minimum?: int,
* maximum?: int,
* }
* required?: bool,
* }|array{}
*/
private function processRule(mixed $rule): array
{
@@ -82,11 +78,12 @@ class RuleToSchemaMapper
return match ($ruleName) {
'required' => ['required' => true],
'string' => ['type' => 'string'],
'integer' => ['type' => 'integer'],
'numeric' => ['type' => 'number'],
'boolean' => ['type' => 'boolean'],
'array' => ['type' => 'array'],
'string' => ['type' => SchemaPropertyType::STRING],
'integer' => ['type' => SchemaPropertyType::INTEGER],
'numeric' => ['type' => SchemaPropertyType::NUMBER],
'boolean' => ['type' => SchemaPropertyType::BOOLEAN],
'array' => ['type' => SchemaPropertyType::ARRAY],
'json' => ['type' => SchemaPropertyType::OBJECT],
'email' => $this->setFormat('email'),
'uuid' => $this->setFormat('uuid'),
'date' => $this->setFormat('date-time'),
@@ -99,27 +96,21 @@ class RuleToSchemaMapper
}
/**
* Handles custom validation rule objects.
*
* Analyzes specific rule types like Enum and In to extract constraint
* information, falling back to string type for unknown rules.
*
* @return array{type: 'integer'|'string', enum?: SchemaPropertyEnumShape|null}
* @return array{type: SchemaPropertyType, enum?: ?non-empty-array<array-key, scalar>}
*/
private function processObjectRule(object $rule): array
{
return match (true) {
$rule instanceof Enum => EnumRuleProcessor::process($rule),
$rule instanceof In => InRuleProcessor::process($rule),
default => ['type' => 'string'],
default => ['type' => SchemaPropertyType::STRING],
};
}
/**
* Sets the format specification for the property.
*
* @param SchemaPropertyFormatsShape $format
* @return array{format: SchemaPropertyFormatsShape, type?: 'string'}
* @return array{format: string, type?: SchemaPropertyType}
*/
private function setFormat(string $format): array
{
@@ -127,20 +118,15 @@ class RuleToSchemaMapper
// Email, UUID, and date-time are all string-based formats in JSON Schema
if (in_array($format, ['email', 'uuid', 'date-time'], true)) {
$result['type'] = 'string';
$result['type'] = SchemaPropertyType::STRING;
}
return $result;
}
/**
* Sets enum constraints from validation rule parameters.
*
* The 'in' rule provides explicit allowed values that must be preserved
* in the schema for proper validation.
*
* @param array<int, mixed> $params
* @return array{enum: SchemaPropertyEnumShape}|array{}
* @param array<array-key, scalar> $params
* @return array{enum: non-empty-array<array-key, scalar>}|array{}
*/
private function setEnum(array $params): array
{

View File

@@ -0,0 +1,73 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
/**
* Schema property for array types.
*
* Supports JSON Schema array validation including:
* - Item schema definition (can be any property type)
* - Size constraints (minItems, maxItems)
*
* Arrays can contain:
* - Primitives (string, integer, boolean)
* - Objects
* - Nested arrays
*/
class ArraySchemaProperty implements SchemaPropertyInterface
{
public function __construct(
private readonly string $name,
private readonly bool $required = false,
private readonly ?SchemaPropertyInterface $schemaProperty = null,
private readonly ?int $minItems = null,
private readonly ?int $maxItems = null,
) {}
public function getName(): string
{
return $this->name;
}
public function isRequired(): bool
{
return $this->required;
}
/**
* Get the items schema (for nested structure building).
*/
public function getItemsSchema(): ?SchemaPropertyInterface
{
return $this->schemaProperty;
}
public function getType(): SchemaPropertyType
{
return SchemaPropertyType::ARRAY;
}
public function toJsonSchema(): array
{
$properties = [
'type' => $this->getType()->value,
];
if ($this->schemaProperty instanceof \Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface) {
$properties['items'] = $this->schemaProperty->toJsonSchema();
}
if ($this->minItems !== null) {
$properties['minItems'] = $this->minItems;
}
if ($this->maxItems !== null) {
$properties['maxItems'] = $this->maxItems;
}
return $properties;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
/**
* Schema property for boolean types.
*
* Simple boolean type with no additional constraints.
*/
class BooleanSchemaProperty implements SchemaPropertyInterface
{
public function __construct(
private readonly string $name,
private readonly bool $required = false,
) {}
public function getName(): string
{
return $this->name;
}
public function isRequired(): bool
{
return $this->required;
}
public function getType(): SchemaPropertyType
{
return SchemaPropertyType::BOOLEAN;
}
public function toJsonSchema(): array
{
return [
'type' => $this->getType()->value,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
/**
* Schema property for integer types.
*
* Supports JSON Schema integer validation including:
* - Range constraints (minimum, maximum)
* - Enum constraints
*/
class IntegerSchemaProperty implements SchemaPropertyInterface
{
/**
* @param array<array-key, scalar>|null $enum
*/
public function __construct(
private readonly string $name,
private readonly bool $required = false,
private readonly ?int $minimum = null,
private readonly ?int $maximum = null,
private readonly ?array $enum = null,
) {}
public function getName(): string
{
return $this->name;
}
public function isRequired(): bool
{
return $this->required;
}
public function getType(): SchemaPropertyType
{
return SchemaPropertyType::INTEGER;
}
public function toJsonSchema(): array
{
$properties = [
'type' => $this->getType()->value,
];
if ($this->minimum !== null) {
$properties['minimum'] = $this->minimum;
}
if ($this->maximum !== null) {
$properties['maximum'] = $this->maximum;
}
if ($this->enum !== null && $this->enum !== []) {
$properties['enum'] = $this->enum;
}
return $properties;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
/**
* Schema property for number types (floating point).
*
* Supports JSON Schema number validation including:
* - Range constraints (minimum, maximum)
*/
class NumberSchemaProperty implements SchemaPropertyInterface
{
public function __construct(
private readonly string $name,
private readonly bool $required = false,
private readonly ?float $minimum = null,
private readonly ?float $maximum = null,
) {}
public function getName(): string
{
return $this->name;
}
public function isRequired(): bool
{
return $this->required;
}
public function getType(): SchemaPropertyType
{
return SchemaPropertyType::NUMBER;
}
public function toJsonSchema(): array
{
$properties = [
'type' => $this->getType()->value,
];
if ($this->minimum !== null) {
$properties['minimum'] = $this->minimum;
}
if ($this->maximum !== null) {
$properties['maximum'] = $this->maximum;
}
return $properties;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
/**
* Schema property for object types.
*
* Supports JSON Schema object validation including:
* - Nested properties (defined via Schema)
* - Required properties list
* - Additional properties control
*
* Objects can contain any combination of property types,
* including nested objects and arrays.
*/
class ObjectSchemaProperty implements SchemaPropertyInterface
{
public function __construct(
private readonly string $name,
private readonly bool $required = false,
private readonly ?Schema $schema = null,
private readonly bool $additionalProperties = false,
) {}
public function getName(): string
{
return $this->name;
}
public function isRequired(): bool
{
return $this->required;
}
/**
* Get the properties schema (for nested structure building).
*/
public function getPropertiesSchema(): ?Schema
{
return $this->schema;
}
public function getType(): SchemaPropertyType
{
return SchemaPropertyType::OBJECT;
}
public function toJsonSchema(): array
{
$result = [
'type' => $this->getType()->value,
];
if ($this->schema instanceof \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema && ! $this->schema->isEmpty()) {
$result['properties'] = $this->schema->toPropertiesArray();
$result['required'] = $this->schema->getRequiredProperties();
}
$result['additionalProperties'] = $this->additionalProperties;
return $result;
}
}

View File

@@ -5,27 +5,26 @@ namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
/**
* @phpstan-import-type SchemaPropertyShape from SchemaProperty
*
* @phpstan-type SchemaShape array{
* '$schema': 'https://json-schema.org/draft/2020-12/schema',
* type: 'object',
* properties: array<string, SchemaPropertyShape>,
* properties: array<string, array<string, mixed>>,
* required: string[],
* additionalProperties: false,
* }
*
* @implements Arrayable<string, SchemaPropertyShape>
* @implements Arrayable<string, mixed>
*/
class Schema implements Arrayable
{
/** @var SchemaProperty[] */
/** @var SchemaPropertyInterface[] */
public readonly array $properties;
/**
* @param SchemaProperty[] $properties
* @param SchemaPropertyInterface[] $properties
*/
public function __construct(
array $properties,
@@ -52,36 +51,49 @@ class Schema implements Arrayable
public function getRequiredProperties(): array
{
return collect($this->properties)
->filter(fn (SchemaProperty $schemaProperty): bool => $schemaProperty->required)
->map(fn (SchemaProperty $schemaProperty): string => $schemaProperty->name)
->filter(fn (SchemaPropertyInterface $schemaProperty): bool => $schemaProperty->isRequired())
->map(fn (SchemaPropertyInterface $schemaProperty): string => $schemaProperty->getName())
->values()
->all();
}
public function toArray(): array
/**
* Convert properties to JSON Schema format (for nested objects).
*
* This method is used when serializing object properties to JSON Schema.
* It produces a map of property names to their JSON Schema representations.
*
* @return array<string, array<string, mixed>>
*/
public function toPropertiesArray(): array
{
return Arr::mapWithKeys(
$this->properties,
fn (SchemaProperty $schemaProperty): array => [
$schemaProperty->name => $schemaProperty->toArray(),
fn (SchemaPropertyInterface $schemaProperty): array => [
$schemaProperty->getName() => $schemaProperty->toJsonSchema(),
]
);
}
public function toArray(): array
{
return $this->toJsonSchema();
}
/**
* Converts this schema to proper JSON Schema format.
*
* Generates a complete JSON Schema object with all necessary metadata
* that can be used directly by JSON Schema validators and editors.
*
* @return SchemaShape
* @return array<string, mixed>
*/
public function toJsonSchema(): array
{
return [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => $this->toArray(),
'properties' => $this->toPropertiesArray(),
'required' => $this->getRequiredProperties(),
'additionalProperties' => false,
];

View File

@@ -1,120 +0,0 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Illuminate\Support\Arr;
// TODO [Refactor] Refactor this into specialized classes with proper support for JSONSchema.
// E.g. IntegerSchemaProperty, ObjectSchemaProperty, etc.
// Most likely its own package.
/**
* @phpstan-type SchemaPropertyTypesShape 'number'|'integer'|'array'|'object'|'string'|'boolean'
* @phpstan-type SchemaPropertyFormatsShape 'uuid'|'email'|'date-time'
* @phpstan-type SchemaPropertyEnumShape array<array-key, scalar>
* TODO [Documentation] Figure out how to annotate the `items` and `properties` recursively.`
* @phpstan-type SchemaPropertyShape array{
* type: SchemaPropertyTypesShape,
* x-name: string,
* x-required: bool,
* format?: SchemaPropertyFormatsShape,
* enum?: SchemaPropertyEnumShape,
* items?: array<array-key, mixed>,
* properties?: array<string, array<array-key, mixed>>,
* required?: bool,
* minLength?: int,
* minimum?: int,
* maxLength?: int,
* maximum?: int,
* }
*/
class SchemaProperty
{
/**
* @param SchemaPropertyTypesShape $type
* @param SchemaPropertyFormatsShape|null $format
* @param SchemaPropertyEnumShape|null $enum Allowed enum values.
*/
public function __construct(
public readonly string $name,
public readonly string $type = 'string', // <- Make this an enum.
public readonly bool $required = false,
public readonly ?string $format = null, // <- Make this an enum.
public readonly ?array $enum = null,
public readonly ?SchemaProperty $itemsSchema = null, // <- For arrays
public readonly ?Schema $propertiesSchema = null, // <- For objects
public readonly ?int $minimum = null,
public readonly ?int $maximum = null,
) {}
/**
* @return SchemaPropertyShape
*/
public function toArray(): array
{
$result = [
'type' => $this->type,
];
if ($this->format !== null) {
$result['format'] = $this->format;
}
if ($this->enum !== null && $this->enum !== []) {
$result['enum'] = $this->enum;
}
if ($this->itemsSchema instanceof \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty) {
$result['items'] = $this->itemsSchema->toArray();
}
if ($this->propertiesSchema instanceof \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema) {
$result['properties'] = Arr::mapWithKeys(
$this->propertiesSchema->properties,
fn (SchemaProperty $schemaProperty): array => [$schemaProperty->name => $schemaProperty->toArray()]
);
$result['required'] = $this->propertiesSchema->getRequiredProperties();
}
if ($this->minimum !== null && in_array($this->type, ['string', 'integer', 'number'])) {
$minPropertyName = $this->type === 'string' ? 'minLength' : 'minimum';
$result[$minPropertyName] = $this->minimum;
}
if ($this->maximum !== null && in_array($this->type, ['string', 'integer', 'number'])) {
$minPropertyName = $this->type === 'string' ? 'maxLength' : 'maximum';
$result[$minPropertyName] = $this->maximum;
}
/**
* @var SchemaPropertyShape $final PHPStan couldn't infer the correct type when building the array incrementally.
*/
$final = array_merge(
$result,
// To make dealing with props easier, for instance, for payload generation.
// We also add a couple of custom properties to the schema.
$this->getCustomProperties(),
);
return $final;
}
/**
* @return array{
* x-name: string,
* x-required: bool,
* }
*/
private function getCustomProperties(): array
{
return [
// Keep in mind: the `required` property is reserved for objects to tell what properties are required in the object.
// this custom property, on the other hand, is to tell if the current property is required or not.
'x-required' => $this->required,
'x-name' => $this->name,
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Contracts\SchemaPropertyInterface;
use Sunchayn\Nimbus\Modules\Schemas\Enums\SchemaPropertyType;
use Sunchayn\Nimbus\Modules\Schemas\Enums\StringFormat;
/**
* Schema property for string types.
*
* Supports JSON Schema string validation including:
* - Format validation (email, uuid, date-time, etc.)
* - Enum constraints
* - Length constraints (minLength, maxLength)
*/
class StringSchemaProperty implements SchemaPropertyInterface
{
/**
* @param array<array-key, scalar>|null $enum
*/
public function __construct(
private readonly string $name,
private readonly bool $required = false,
private readonly ?StringFormat $stringFormat = null,
private readonly ?array $enum = null,
private readonly ?int $minLength = null,
private readonly ?int $maxLength = null,
private readonly ?string $pattern = null,
private readonly mixed $const = null,
) {}
public function getName(): string
{
return $this->name;
}
public function isRequired(): bool
{
return $this->required;
}
public function getType(): SchemaPropertyType
{
return SchemaPropertyType::STRING;
}
public function toJsonSchema(): array
{
$properties = [
'type' => $this->getType()->value,
];
if ($this->stringFormat instanceof \Sunchayn\Nimbus\Modules\Schemas\Enums\StringFormat) {
$properties['format'] = $this->stringFormat->value;
}
if ($this->enum !== null && $this->enum !== []) {
$properties['enum'] = $this->enum;
}
if ($this->minLength !== null) {
$properties['minLength'] = $this->minLength;
}
if ($this->maxLength !== null) {
$properties['maxLength'] = $this->maxLength;
}
if ($this->pattern !== null) {
$properties['pattern'] = $this->pattern;
}
if ($this->const !== null) {
$properties['const'] = $this->const;
}
return $properties;
}
}