diff --git a/README.md b/README.md index ca4fd4a..30c164d 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,19 @@ This creates a structure class at `app/Structures/UserProfile.php`: namespace App\Structures; -use Blaspsoft\Forerunner\Schemas\Struct; -use Blaspsoft\Forerunner\Schemas\Builder; +use Blaspsoft\Forerunner\Schema\Struct; +use Blaspsoft\Forerunner\Schema\Property; class UserProfile { public static function schema(): array { - return Struct::define('user_profile', function (Builder $table) { - $table->string('example_field')->required(); + return Struct::define('user_profile', 'Description of user_profile', function (Property $property) { + $property->string('example_field'); // Add your fields here - }); + + $property->strict(); // All fields required + no additional properties + })->toArray(); } } ``` @@ -64,27 +66,27 @@ class UserProfile Define a schema using the `Struct` class or `Schema` facade: ```php -use Blaspsoft\Forerunner\Schemas\Struct; -use Blaspsoft\Forerunner\Schemas\Builder; - -$schema = Struct::define('User', function (Builder $builder) { - $builder->string('name', 'The user\'s full name')->required(); - $builder->string('email', 'The user\'s email address')->required(); - $builder->int('age', 'The user\'s age')->min(0)->max(150); - $builder->boolean('is_active', 'Is the user account active?')->default(true); -}); +use Blaspsoft\Forerunner\Schema\Struct; +use Blaspsoft\Forerunner\Schema\Property; + +$schema = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('name', 'The user\'s full name')->required(); + $property->string('email', 'The user\'s email address')->required(); + $property->int('age', 'The user\'s age')->min(0)->max(150); + $property->boolean('is_active', 'Is the user account active?')->default(true); +})->toArray(); ``` -Or using the facade: +Or using the facade for a cleaner syntax: ```php use Blaspsoft\Forerunner\Facades\Schema; -use Blaspsoft\Forerunner\Schemas\Builder; +use Blaspsoft\Forerunner\Schema\Property; -$schema = Schema::define('User', function (Builder $builder) { - $builder->string('name')->required(); - $builder->string('email')->required(); -}); +$schema = Schema::define('User', 'A user schema', function (Property $property) { + $property->string('name', 'The user\'s full name')->required(); + $property->string('email', 'The user\'s email address')->required(); +})->toArray(); ``` ## Available Field Types @@ -92,7 +94,7 @@ $schema = Schema::define('User', function (Builder $builder) { ### String Fields ```php -$builder->string('username', 'The username') +$property->string('username', 'The username') ->minLength(3) ->maxLength(50) ->pattern('^[a-zA-Z0-9_]+$') @@ -102,47 +104,47 @@ $builder->string('username', 'The username') ### Integer Fields ```php -$builder->int('age', 'User age') +$property->int('age', 'User age') ->min(0) ->max(150) ->default(18); // Alias -$builder->integer('count'); +$property->integer('count'); ``` ### Float/Number Fields ```php -$builder->float('price', 'Product price') +$property->float('price', 'Product price') ->min(0.0) ->max(9999.99); // Alias -$builder->number('rating')->min(0)->max(5); +$property->number('rating')->min(0)->max(5); ``` ### Boolean Fields ```php -$builder->boolean('is_active', 'Account status') +$property->boolean('is_active', 'Account status') ->default(true); // Alias -$builder->bool('verified'); +$property->bool('verified'); ``` ### Array Fields ```php // Simple array -$builder->array('tags', 'User tags') +$property->array('tags', 'User tags') ->items('string') ->minItems(1) ->maxItems(10); // Array of objects -$builder->array('addresses')->items('object', function (Builder $item) { +$property->array('addresses')->items('object', function (Property $item) { $item->string('street')->required(); $item->string('city')->required(); $item->string('zip')->required(); @@ -152,20 +154,20 @@ $builder->array('addresses')->items('object', function (Builder $item) { ### Enum Fields ```php -$builder->enum('role', ['admin', 'user', 'guest'], 'User role') +$property->enum('role', ['admin', 'user', 'guest'], 'User role') ->default('user'); -$builder->enum('status', ['draft', 'published', 'archived']); +$property->enum('status', ['draft', 'published', 'archived']); ``` ### Object Fields ```php -$builder->object('address', function (Builder $nested) { +$property->object('address', function (Property $nested) { $nested->string('street', 'Street address')->required(); $nested->string('city', 'City name')->required(); $nested->string('zip', 'ZIP code')->required(); - $nested->object('coordinates', function (Builder $coords) { + $nested->object('coordinates', function (Property $coords) { $coords->float('latitude')->required(); $coords->float('longitude')->required(); }); @@ -177,7 +179,7 @@ $builder->object('address', function (Builder $nested) { ### String Constraints ```php -$builder->string('username') +$property->string('username') ->minLength(3) // Minimum length ->maxLength(50) // Maximum length ->pattern('^[a-zA-Z0-9]+$') // Regex pattern @@ -187,7 +189,7 @@ $builder->string('username') ### Numeric Constraints ```php -$builder->int('age') +$property->int('age') ->min(0) // Minimum value ->max(150) // Maximum value ->default(18); // Default value @@ -196,7 +198,7 @@ $builder->int('age') ### Array Constraints ```php -$builder->array('tags') +$property->array('tags') ->items('string') // Type of array items ->minItems(1) // Minimum array length ->maxItems(10); // Maximum array length @@ -205,7 +207,7 @@ $builder->array('tags') ### General Constraints ```php -$builder->string('field') +$property->string('field') ->required() // Mark as required ->optional() // Mark as optional (default) ->default('value') // Set default value @@ -220,31 +222,31 @@ Forerunner provides convenient helper methods for commonly used field formats: ```php // Email field with automatic format validation -$builder->email('email')->required(); +$property->email('email')->required(); // URL field -$builder->url('website'); +$property->url('website'); // UUID field -$builder->uuid('id')->required(); +$property->uuid('id')->required(); // Date-time field (ISO 8601) -$builder->datetime('created_at'); +$property->datetime('created_at'); // Date field -$builder->date('birth_date'); +$property->date('birth_date'); // Time field -$builder->time('start_time'); +$property->time('start_time'); // IPv4 address -$builder->ipv4('ip_address'); +$property->ipv4('ip_address'); // IPv6 address -$builder->ipv6('ipv6_address'); +$property->ipv6('ipv6_address'); // Hostname -$builder->hostname('server_name'); +$property->hostname('server_name'); ``` ### String Format Validation @@ -252,9 +254,9 @@ $builder->hostname('server_name'); You can also set custom formats on string fields: ```php -$builder->string('email')->format('email'); -$builder->string('website')->format('uri'); -$builder->string('id')->format('uuid'); +$property->string('email')->format('email'); +$property->string('website')->format('uri'); +$property->string('id')->format('uuid'); ``` Supported formats: `email`, `uri`, `url`, `uuid`, `date`, `date-time`, `time`, `ipv4`, `ipv6`, `hostname`, and more. @@ -264,10 +266,10 @@ Supported formats: `email`, `uri`, `url`, `uuid`, `date`, `date-time`, `time`, ` Mark fields as nullable to allow both the specified type and null: ```php -$builder->string('middle_name')->nullable(); +$property->string('middle_name')->nullable(); // Generates: {"type": ["string", "null"]} -$builder->object('address', function (Builder $nested) { +$property->object('address', function (Property $nested) { $nested->string('street')->required(); $nested->string('city')->required(); })->nullable(); @@ -279,7 +281,7 @@ $builder->object('address', function (Builder $nested) { Ensure array items are unique: ```php -$builder->array('tags') +$property->array('tags') ->items('string') ->uniqueItems(); ``` @@ -290,13 +292,13 @@ Control whether objects can have properties not defined in the schema: ```php // Allow additional properties -$builder->additionalProperties(true); +$property->additionalProperties(true); // Disallow additional properties -$builder->additionalProperties(false); // This is the default +$property->additionalProperties(false); // This is the default // Or use the convenient strict() helper -$builder->strict(); // Disallows additional properties AND marks all fields as required +$property->strict(); // Disallows additional properties AND marks all fields as required ``` #### Strict Mode for LLM APIs @@ -305,16 +307,19 @@ The `strict()` method is particularly useful for LLM APIs like **OpenAI Structur 1. `additionalProperties: false` 2. All properties in the `required` array +**Important:** Call `strict()` after defining all your fields to ensure all of them are marked as required. + ```php // Perfect for OpenAI Structured Outputs -$schema = Struct::define('User', function (Builder $builder) { - $builder->string('fullname'); - $builder->email('email'); - $builder->int('age')->min(0)->max(120); - $builder->string('location'); +$schema = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('fullname'); + $property->email('email'); + $property->int('age')->min(0)->max(120); + $property->string('location'); - $builder->strict(); // Makes all fields required + disallows extra properties -}); + // Call strict() at the end to mark all fields as required + $property->strict(); // Makes all fields required + disallows extra properties +})->toArray(); ``` This generates: @@ -327,22 +332,22 @@ This generates: } ``` -> **Note**: By default, `additionalProperties` is already set to `false`. Use `strict()` when you also need all fields to be required (like for OpenAI). +> **Note**: By default, `additionalProperties` is already set to `false`. Use `strict()` when you also need all fields to be required (like for OpenAI). Call it after defining fields to ensure all are marked as required. ### Schema Metadata Add metadata to your schemas: ```php -$builder->title('User Schema'); -$builder->description('Schema for user data validation'); -$builder->schemaVersion('https://json-schema.org/draft/2020-12/schema'); +$property->title('User Schema'); +$property->description('Schema for user data validation'); +$property->schemaVersion('https://json-schema.org/draft/2020-12/schema'); ``` You can also add titles to individual fields: ```php -$builder->string('email') +$property->string('email') ->title('Email Address') ->description('User\'s primary email address') ->format('email') @@ -352,44 +357,45 @@ $builder->string('email') ### Complete Advanced Example ```php -use Blaspsoft\Forerunner\Schemas\Struct; -use Blaspsoft\Forerunner\Schemas\Builder; +use Blaspsoft\Forerunner\Schema\Struct; +use Blaspsoft\Forerunner\Schema\Property; -$schema = Struct::define('AdvancedUser', function (Builder $builder) { +$schema = Struct::define('AdvancedUser', 'Comprehensive user data structure', function (Property $property) { // Schema metadata - $builder->schemaVersion(); - $builder->title('Advanced User Schema'); - $builder->description('Comprehensive user data structure'); - $builder->strict(); // Disallow additional properties + $property->schemaVersion(); + $property->title('Advanced User Schema'); // Helper methods - $builder->uuid('id')->required(); - $builder->email('email')->required(); - $builder->url('website')->nullable(); - $builder->datetime('created_at')->required(); + $property->uuid('id')->required(); + $property->email('email')->required(); + $property->url('website')->nullable(); + $property->datetime('created_at')->required(); // Nullable nested object - $builder->object('profile', function (Builder $profile) { + $property->object('profile', function (Property $profile) { $profile->string('bio')->maxLength(500); $profile->string('avatar_url')->format('uri'); })->nullable(); // Array with unique items - $builder->array('tags') + $property->array('tags') ->items('string') ->uniqueItems() ->minItems(1) ->maxItems(10); // Advanced field configuration - $builder->string('username') + $property->string('username') ->title('Username') ->description('Unique username for the account') ->minLength(3) ->maxLength(30) ->pattern('^[a-zA-Z0-9_]+$') ->required(); -}); + + // Call strict() after all fields to mark them all as required + $property->strict(); // Disallow extras + mark all defined fields as required +})->toArray(); ``` This generates: @@ -458,32 +464,32 @@ This generates: ### User Profile with Nested Objects ```php -use Blaspsoft\Forerunner\Schemas\Struct; -use Blaspsoft\Forerunner\Schemas\Builder; +use Blaspsoft\Forerunner\Schema\Struct; +use Blaspsoft\Forerunner\Schema\Property; -$schema = Struct::define('UserProfile', function (Builder $builder) { - $builder->string('name', 'The user\'s full name') +$schema = Struct::define('UserProfile', 'A complete user profile schema', function (Property $property) { + $property->string('name', 'The user\'s full name') ->minLength(1) ->maxLength(100) ->required(); - $builder->string('email', 'The user\'s email') + $property->string('email', 'The user\'s email') ->pattern('^[^\s@]+@[^\s@]+\.[^\s@]+$') ->required(); - $builder->int('age', 'The user\'s age') + $property->int('age', 'The user\'s age') ->min(0) ->max(150); - $builder->boolean('is_active', 'Is the account active?') + $property->boolean('is_active', 'Is the account active?') ->default(true); - $builder->array('tags', 'User tags') + $property->array('tags', 'User tags') ->items('string') ->minItems(0) ->maxItems(10); - $builder->object('address', function (Builder $address) { + $property->object('address', function (Property $address) { $address->string('street', 'Street name')->required(); $address->string('city', 'City name')->required(); $address->string('state', 'State/Province')->required(); @@ -491,101 +497,55 @@ $schema = Struct::define('UserProfile', function (Builder $builder) { $address->string('country', 'Country code')->required(); }, 'User\'s address'); - $builder->enum('role', ['admin', 'moderator', 'user'], 'User role') + $property->enum('role', ['admin', 'moderator', 'user'], 'User role') ->default('user'); -}); +})->toArray(); ``` ### Blog Post with Comments ```php -$schema = Struct::define('BlogPost', function (Builder $builder) { - $builder->string('title')->required(); - $builder->string('content')->required(); - $builder->string('slug')->pattern('^[a-z0-9-]+$')->required(); +$schema = Struct::define('BlogPost', 'A blog post with author and comments', function (Property $property) { + $property->string('title')->required(); + $property->string('content')->required(); + $property->string('slug')->pattern('^[a-z0-9-]+$')->required(); - $builder->object('author', function (Builder $author) { + $property->object('author', function (Property $author) { $author->string('name')->required(); $author->string('email')->required(); $author->string('bio'); })->required(); - $builder->array('comments')->items('object', function (Builder $comment) { + $property->array('comments')->items('object', function (Property $comment) { $comment->string('text')->required(); $comment->string('author_name')->required(); $comment->string('author_email')->required(); $comment->int('timestamp')->required(); }); - $builder->array('tags')->items('string')->minItems(1); + $property->array('tags')->items('string')->minItems(1); - $builder->enum('status', ['draft', 'published', 'archived']) + $property->enum('status', ['draft', 'published', 'archived']) ->default('draft'); - $builder->int('views')->min(0)->default(0); -}); + $property->int('views')->min(0)->default(0); +})->toArray(); ``` ## Working with Generated Schemas -The `Struct::define()` method returns a `Struct` object that provides multiple ways to access your schema data. - -### Flexible API: Array Access + Object Methods - -Forerunner schemas support both array-like access and object methods for maximum flexibility: - -```php -$schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); - $builder->string('email')->required(); -}); - -// Access as an array (for backward compatibility) -$type = $schema['type']; // 'object' -$properties = $schema['properties']; // array of properties - -// Or use object methods for a fluent API -$array = $schema->toArray(); // Get as PHP array -$json = $schema->toJson(); // Get as JSON string - -// Method chaining works too! -$json = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); -})->toJson(); -``` +The `Struct::define()` method returns a `Struct` object that can be converted to an array or JSON. ### Convert to Array ```php -$schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); -}); - -// Use as array directly (implements ArrayAccess) -foreach ($schema['properties'] as $name => $property) { - // Process properties -} - -// Or explicitly convert to array -$array = $schema->toArray(); -``` - -### Convert to JSON String - -```php -// Direct method chaining -$json = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); - $builder->email('email')->required(); -})->toJson(); - -// Or call toJson() on the schema object -$schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); +$struct = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('name')->required(); + $property->string('email')->required(); }); -$json = $schema->toJson(); -// Both return formatted JSON strings +// Convert to array +$array = $struct->toArray(); ``` ### JSON Serialization @@ -593,60 +553,38 @@ $json = $schema->toJson(); The `Struct` object implements `JsonSerializable`, so you can use it directly with `json_encode()`: ```php -$schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); +$struct = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('name')->required(); }); // Automatic JSON serialization -$json = json_encode($schema, JSON_PRETTY_PRINT); +$json = json_encode($struct, JSON_PRETTY_PRINT); ``` ### Using Structure Classes -When using the `make:struct` command, you can leverage the new object methods: +When using the `make:struct` command, you can create reusable schema classes: ```php // In your structure class (generated by make:struct) class UserProfile { - public static function schema(): Struct - { - return Struct::define('user_profile', function (Builder $table) { - $table->string('name')->required(); - $table->string('email')->required(); - }); - } - - public static function toJson(): string - { - return static::schema()->toJson(); - } - - public static function toArray(): array + public static function schema(): array { - return static::schema()->toArray(); + return Struct::define('user_profile', 'A user profile schema', function (Property $property) { + $property->string('name')->required(); + $property->string('email')->required(); + })->toArray(); } } // Using the structure -$schema = UserProfile::schema(); // Returns Struct object -$array = UserProfile::toArray(); // Returns array -$json = UserProfile::toJson(); // Returns JSON string - -// Or chain methods directly -$json = UserProfile::schema()->toJson(); -``` - -## Configuration - -Publish the configuration file: +$array = UserProfile::schema(); // Returns array -```bash -php artisan vendor:publish --tag="forerunner-config" +// For JSON, use json_encode +$json = json_encode(UserProfile::schema(), JSON_PRETTY_PRINT); ``` -This will create `config/forerunner.php` where you can customize package settings. - ## Testing Run the test suite: diff --git a/config/forerunner.php b/config/forerunner.php deleted file mode 100644 index 280c2dc..0000000 --- a/config/forerunner.php +++ /dev/null @@ -1,16 +0,0 @@ - [ - // Add your default configuration options here - ], -]; diff --git a/phpstan.neon b/phpstan.neon index 2310cea..ee178b1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,5 +5,4 @@ parameters: level: 9 paths: - src - - config tmpDir: build/phpstan diff --git a/src/Commands/MakeStructCommand.php b/src/Commands/MakeStructCommand.php index 32a7bdd..43f8e57 100644 --- a/src/Commands/MakeStructCommand.php +++ b/src/Commands/MakeStructCommand.php @@ -1,5 +1,7 @@ define(string $name, callable $callback) + * @method static \Blaspsoft\Forerunner\Schema\Struct define(string $name, string $description, callable $callback) * - * @see \Blaspsoft\Forerunner\Schemas\Struct + * @see \Blaspsoft\Forerunner\Schema\Struct */ class Schema extends Facade { diff --git a/src/ForerunnerServiceProvider.php b/src/ForerunnerServiceProvider.php index b976f29..7a8f825 100644 --- a/src/ForerunnerServiceProvider.php +++ b/src/ForerunnerServiceProvider.php @@ -1,9 +1,11 @@ mergeConfigFrom( - __DIR__.'/../config/forerunner.php', - 'forerunner' - ); - $this->app->singleton('forerunner.schema', function () { return new class { @@ -46,10 +43,6 @@ public function __call(string $method, array $args): mixed public function boot(): void { if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__.'/../config/forerunner.php' => config_path('forerunner.php'), - ], 'forerunner-config'); - $this->commands([ MakeStructCommand::class, ]); diff --git a/src/Schemas/Builder.php b/src/Schema/Property.php similarity index 91% rename from src/Schemas/Builder.php rename to src/Schema/Property.php index fd80a88..61f263f 100644 --- a/src/Schemas/Builder.php +++ b/src/Schema/Property.php @@ -1,17 +1,16 @@ */ protected array $properties = []; - /** @var array */ - protected array $required = []; - protected ?string $description = null; protected bool $additionalProperties = false; @@ -20,6 +19,8 @@ class Builder protected ?string $title = null; + protected bool $isStrict = false; + public function __construct(string $name) { $this->name = $name; @@ -94,7 +95,7 @@ public function array(string $name, ?string $description = null): PropertyBuilde */ public function object(string $name, callable $callback, ?string $description = null): PropertyBuilder { - $nestedBuilder = new Builder($name); + $nestedBuilder = new Property($name); $callback($nestedBuilder); $builder = new PropertyBuilder($name, 'object', $description); @@ -226,6 +227,7 @@ public function additionalProperties(bool $allowed = true): self */ public function strict(): self { + $this->isStrict = true; $this->additionalProperties = false; // Mark all properties as required @@ -236,6 +238,14 @@ public function strict(): self return $this; } + /** + * Check if strict mode is enabled. + */ + public function isStrict(): bool + { + return $this->isStrict; + } + /** * Set the JSON Schema version. */ @@ -252,21 +262,17 @@ public function schemaVersion(string $version = 'https://json-schema.org/draft/2 protected function addProperty(string $name, string $type, ?string $description): PropertyBuilder { $builder = new PropertyBuilder($name, $type, $description); + + // In strict mode, all fields must be required, including those added after strict() + if ($this->isStrict) { + $builder->required(); + } + $this->properties[$name] = $builder; return $builder; } - /** - * Mark a field as required. - */ - public function markRequired(string $name): void - { - if (! in_array($name, $this->required)) { - $this->required[] = $name; - } - } - /** * Convert the builder to a JSON schema array. * @@ -291,16 +297,18 @@ public function toArray(): array $schema['description'] = $this->description; } + // Build required array from property states without mutating $this->required + $required = []; foreach ($this->properties as $name => $builder) { $schema['properties'][$name] = $builder->toArray(); if ($builder->isRequired()) { - $this->markRequired($name); + $required[] = $name; } } - if (! empty($this->required)) { - $schema['required'] = $this->required; + if (! empty($required)) { + $schema['required'] = $required; } $schema['additionalProperties'] = $this->additionalProperties; diff --git a/src/Schemas/PropertyBuilder.php b/src/Schema/PropertyBuilder.php similarity index 96% rename from src/Schemas/PropertyBuilder.php rename to src/Schema/PropertyBuilder.php index fd98707..d638cc6 100644 --- a/src/Schemas/PropertyBuilder.php +++ b/src/Schema/PropertyBuilder.php @@ -1,6 +1,8 @@ |null */ protected ?array $enum = null; - protected ?Builder $nestedBuilder = null; + protected ?Property $nestedBuilder = null; /** @var array|null */ protected ?array $items = null; @@ -99,7 +101,7 @@ public function enum(array $values): self public function items(string $type, ?callable $callback = null): self { if ($callback && $type === 'object') { - $nestedBuilder = new Builder($this->name.'_item'); + $nestedBuilder = new Property($this->name.'_item'); $callback($nestedBuilder); $this->items = $nestedBuilder->toArray(); } else { @@ -232,7 +234,7 @@ public function nullable(bool $nullable = true): self /** * Set a nested builder for object types. */ - public function setNestedBuilder(Builder $builder): void + public function setNestedBuilder(Property $builder): void { $this->nestedBuilder = $builder; } diff --git a/src/Schema/Struct.php b/src/Schema/Struct.php new file mode 100644 index 0000000..f1321c1 --- /dev/null +++ b/src/Schema/Struct.php @@ -0,0 +1,70 @@ +|null */ + protected ?array $cache = null; + + protected function __construct(string $name, Property $builder) + { + $this->name = $name; + $this->builder = $builder; + } + + /** + * Define a new structure schema. + */ + public static function define(string $name, string $description, callable $callback): self + { + $builder = new Property($name); + $builder->description($description); + + $callback($builder); + + return new self($name, $builder); + } + + /** + * Convert the schema to an array. + * If strict mode is enabled, wraps the schema in OpenAI's format. + * + * @return array + */ + public function toArray(): array + { + if ($this->cache === null) { + $builderArray = $this->builder->toArray(); + + // If strict mode is enabled, wrap in OpenAI's format + if ($this->builder->isStrict()) { + $this->cache = [ + 'name' => $this->name, + 'strict' => true, + 'schema' => $builderArray, + ]; + } else { + $this->cache = $builderArray; + } + } + + return $this->cache; + } + + /** + * Specify data which should be serialized to JSON. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Schemas/Struct.php b/src/Schemas/Struct.php deleted file mode 100644 index 19edc57..0000000 --- a/src/Schemas/Struct.php +++ /dev/null @@ -1,103 +0,0 @@ - - */ -class Struct implements \ArrayAccess, \JsonSerializable -{ - protected Builder $builder; - - protected string $name; - - /** @var array|null */ - protected ?array $cache = null; - - protected function __construct(string $name, Builder $builder) - { - $this->name = $name; - $this->builder = $builder; - } - - /** - * Define a new structure schema. - */ - public static function define(string $name, callable $callback): self - { - $builder = new Builder($name); - $callback($builder); - - return new self($name, $builder); - } - - /** - * Convert the schema to an array. - * - * @return array - */ - public function toArray(): array - { - if ($this->cache === null) { - $this->cache = $this->builder->toArray(); - } - - return $this->cache; - } - - /** - * Convert the schema to a JSON string. - * - * @throws \JsonException - */ - public function toJson(): string - { - return $this->builder->toJson(); - } - - /** - * Check if an offset exists (ArrayAccess). - * - * @param string $offset - */ - public function offsetExists(mixed $offset): bool - { - return isset($this->toArray()[$offset]); - } - - /** - * Get an offset value (ArrayAccess). - * - * @param string $offset - */ - public function offsetGet(mixed $offset): mixed - { - return $this->toArray()[$offset]; - } - - /** - * Set an offset value (ArrayAccess) - not supported. - */ - public function offsetSet(mixed $offset, mixed $value): void - { - throw new \BadMethodCallException('Struct schemas are immutable'); - } - - /** - * Unset an offset (ArrayAccess) - not supported. - */ - public function offsetUnset(mixed $offset): void - { - throw new \BadMethodCallException('Struct schemas are immutable'); - } - - /** - * Specify data which should be serialized to JSON. - * - * @return array - */ - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/stubs/struct.stub b/stubs/struct.stub index 20b9587..bb42bd6 100644 --- a/stubs/struct.stub +++ b/stubs/struct.stub @@ -2,43 +2,23 @@ namespace {{ namespace }}; -use Blaspsoft\Forerunner\Schemas\Struct; -use Blaspsoft\Forerunner\Schemas\Builder; +use Blaspsoft\Forerunner\Schema\Struct; +use Blaspsoft\Forerunner\Schema\Property; class {{ class }} { /** * Define the structure schema. * - * @return Struct - */ - public static function schema(): Struct - { - return Struct::define('{{ structName }}', function (Builder $builder) { - $builder->string('example_field'); - // Add your fields here - - $builder->strict(); // All fields required + no additional properties - }); - } - - /** - * Get the schema as a PHP array. - * * @return array */ - public static function toArray(): array + public static function schema(): array { - return static::schema()->toArray(); - } + return Struct::define('{{ structName }}', 'Description of {{ structName }}', function (Property $property) { + $property->string('example_field'); + // Add your fields here - /** - * Get the schema as JSON string. - * - * @return string - */ - public static function toJson(): string - { - return static::schema()->toJson(); + $property->strict(); // All fields required + no additional properties + })->toArray(); } } \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 5d36321..b27bbda 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,5 +1,6 @@ toBeTrue(); }); diff --git a/tests/Feature/IntendedUsageTest.php b/tests/Feature/IntendedUsageTest.php index bc94e0d..6cc4567 100644 --- a/tests/Feature/IntendedUsageTest.php +++ b/tests/Feature/IntendedUsageTest.php @@ -1,25 +1,26 @@ string('name', 'The name of the user')->minLength(1)->maxLength(100)->required(); - $builder->int('age', 'The age of the user')->min(0)->max(150); - $builder->boolean('is_active', 'Is the user active?')->default(true); - $builder->array('tags', 'Tags associated with the user') + $schema = Struct::define('User', 'A user object with personal information', function (Property $property) { + $property->string('name', 'The name of the user')->minLength(1)->maxLength(100)->required(); + $property->int('age', 'The age of the user')->min(0)->max(150); + $property->boolean('is_active', 'Is the user active?')->default(true); + $property->array('tags', 'Tags associated with the user') ->items('string') ->minItems(0) ->maxItems(10); - $builder->object('address', function (Builder $table) { - $table->string('street', 'Street name')->required(); - $table->string('city', 'City name')->required(); - $table->string('zip', 'ZIP code')->required(); + $property->object('address', function (Property $address) { + $address->string('street', 'Street name')->required(); + $address->string('city', 'City name')->required(); + $address->string('zip', 'ZIP code')->required(); }, 'The address of the user'); - }); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['type'])->toBe('object') ->and($schema['properties'])->toHaveKey('name') ->and($schema['properties'])->toHaveKey('age') diff --git a/tests/Feature/MakeStructCommandTest.php b/tests/Feature/MakeStructCommandTest.php index 80f63b1..98f4524 100644 --- a/tests/Feature/MakeStructCommandTest.php +++ b/tests/Feature/MakeStructCommandTest.php @@ -1,5 +1,6 @@ testFilePath); - expect($content)->toContain('use Blaspsoft\Forerunner\Schemas\Struct;') - ->and($content)->toContain('use Blaspsoft\Forerunner\Schemas\Builder;'); + expect($content)->toContain('use Blaspsoft\Forerunner\Schema\Struct;') + ->and($content)->toContain('use Blaspsoft\Forerunner\Schema\Property;'); }); it('generates struct with schema method', function () { @@ -58,18 +59,9 @@ $content = File::get($this->testFilePath); - expect($content)->toContain('public static function schema(): Struct') - ->and($content)->toContain("Struct::define('test_user_struct'"); -}); - -it('generates struct with toJson method', function () { - $this->artisan('make:struct', ['name' => 'TestUserStruct']) - ->assertExitCode(0); - - $content = File::get($this->testFilePath); - - expect($content)->toContain('public static function toJson(): string') - ->and($content)->toContain('static::schema()->toJson()'); + expect($content)->toContain('public static function schema(): array') + ->and($content)->toContain("Struct::define('test_user_struct'") + ->and($content)->toContain('->toArray()'); }); it('generates struct with strict mode by default', function () { @@ -78,7 +70,7 @@ $content = File::get($this->testFilePath); - expect($content)->toContain('$builder->strict()'); + expect($content)->toContain('$property->strict()'); }); it('converts PascalCase to snake_case for struct name', function () { diff --git a/tests/Feature/SchemaFacadeTest.php b/tests/Feature/SchemaFacadeTest.php index 0043a13..c66a18c 100644 --- a/tests/Feature/SchemaFacadeTest.php +++ b/tests/Feature/SchemaFacadeTest.php @@ -1,17 +1,17 @@ string('name')->required(); - $builder->string('email')->required(); - }); + $schema = Schema::define('User', 'A user schema', function (Property $property) { + $property->string('name')->required(); + $property->string('email')->required(); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['type'])->toBe('object') ->and($schema['properties'])->toHaveKey('name') ->and($schema['properties'])->toHaveKey('email') @@ -20,18 +20,18 @@ }); it('can define complex schemas using the facade', function () { - $schema = Schema::define('BlogPost', function (Builder $builder) { - $builder->string('title')->required(); - $builder->string('content')->required(); - $builder->object('author', function (Builder $author) { + $schema = Schema::define('BlogPost', 'A blog post schema', function (Property $property) { + $property->string('title')->required(); + $property->string('content')->required(); + $property->object('author', function (Property $author) { $author->string('name')->required(); $author->string('email')->required(); })->required(); - $builder->array('tags')->items('string'); - $builder->enum('status', ['draft', 'published', 'archived'])->default('draft'); - }); + $property->array('tags')->items('string'); + $property->enum('status', ['draft', 'published', 'archived'])->default('draft'); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['type'])->toBe('object') ->and($schema['properties'])->toHaveKey('title') ->and($schema['properties'])->toHaveKey('author') @@ -41,16 +41,16 @@ }); it('can define schemas with all field types', function () { - $schema = Schema::define('CompleteExample', function (Builder $builder) { - $builder->string('text'); - $builder->int('count'); - $builder->float('price'); - $builder->boolean('isActive'); - $builder->array('items'); - $builder->enum('role', ['admin', 'user']); - }); - - expect($schema)->toBeInstanceOf(Struct::class) + $schema = Schema::define('CompleteExample', 'Complete example schema', function (Property $property) { + $property->string('text'); + $property->int('count'); + $property->float('price'); + $property->boolean('isActive'); + $property->array('items'); + $property->enum('role', ['admin', 'user']); + })->toArray(); + + expect($schema)->toBeArray() ->and($schema['properties']['text']['type'])->toBe('string') ->and($schema['properties']['count']['type'])->toBe('integer') ->and($schema['properties']['price']['type'])->toBe('number') @@ -61,21 +61,21 @@ }); it('can define schemas with validation constraints', function () { - $schema = Schema::define('ValidationExample', function (Builder $builder) { - $builder->string('username') + $schema = Schema::define('ValidationExample', 'Validation example schema', function (Property $property) { + $property->string('username') ->required() ->minLength(3) ->maxLength(50) ->pattern('^[a-zA-Z0-9_]+$'); - $builder->int('age') + $property->int('age') ->min(0) ->max(150); - $builder->array('tags') + $property->array('tags') ->minItems(1) ->maxItems(10); - }); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['properties']['username'])->toHaveKey('minLength', 3) ->and($schema['properties']['username'])->toHaveKey('maxLength', 50) ->and($schema['properties']['username'])->toHaveKey('pattern') @@ -86,19 +86,19 @@ }); it('can define schemas with nested objects', function () { - $schema = Schema::define('Company', function (Builder $builder) { - $builder->string('name')->required(); - $builder->object('address', function (Builder $address) { + $schema = Schema::define('Company', 'Company information', function (Property $property) { + $property->string('name')->required(); + $property->object('address', function (Property $address) { $address->string('street'); $address->string('city'); - $address->object('coordinates', function (Builder $coords) { + $address->object('coordinates', function (Property $coords) { $coords->float('latitude'); $coords->float('longitude'); }); }); - }); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['properties']['address']['type'])->toBe('object') ->and($schema['properties']['address']['properties'])->toHaveKey('coordinates') ->and($schema['properties']['address']['properties']['coordinates']['type'])->toBe('object') @@ -107,15 +107,15 @@ }); it('can define schemas with array of objects', function () { - $schema = Schema::define('UserList', function (Builder $builder) { - $builder->array('users')->items('object', function (Builder $user) { + $schema = Schema::define('UserList', 'A list of users', function (Property $property) { + $property->array('users')->items('object', function (Property $user) { $user->string('name')->required(); $user->string('email')->required(); $user->int('age'); }); - }); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['properties']['users']['type'])->toBe('array') ->and($schema['properties']['users']['items']['type'])->toBe('object') ->and($schema['properties']['users']['items']['properties'])->toHaveKey('name') @@ -124,26 +124,26 @@ }); it('can define schemas with descriptions', function () { - $schema = Schema::define('DescribedSchema', function (Builder $builder) { - $builder->description('A schema with descriptions'); - $builder->string('name')->description('The user name'); - $builder->string('email')->description('The user email address'); - }); + $schema = Schema::define('DescribedSchema', 'A schema with descriptions', function (Property $property) { + $property->description('A schema with descriptions'); + $property->string('name')->description('The user name'); + $property->string('email')->description('The user email address'); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema)->toHaveKey('description', 'A schema with descriptions') ->and($schema['properties']['name'])->toHaveKey('description', 'The user name') ->and($schema['properties']['email'])->toHaveKey('description', 'The user email address'); }); it('can define schemas with default values', function () { - $schema = Schema::define('DefaultsExample', function (Builder $builder) { - $builder->boolean('notifications')->default(true); - $builder->string('theme')->default('light'); - $builder->int('pageSize')->default(10); - }); + $schema = Schema::define('DefaultsExample', 'Schema with defaults', function (Property $property) { + $property->boolean('notifications')->default(true); + $property->string('theme')->default('light'); + $property->int('pageSize')->default(10); + })->toArray(); - expect($schema)->toBeInstanceOf(Struct::class) + expect($schema)->toBeArray() ->and($schema['properties']['notifications']['default'])->toBe(true) ->and($schema['properties']['theme']['default'])->toBe('light') ->and($schema['properties']['pageSize']['default'])->toBe(10); diff --git a/tests/Pest.php b/tests/Pest.php index 0de6d0a..2d7b904 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,6 @@ in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index e055f90..b745aa2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,5 +1,7 @@ string('name'); - $builder->additionalProperties(true); + $property = new Property('Test'); + $property->string('name'); + $property->additionalProperties(true); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('additionalProperties', true); }); it('can set additionalProperties to false', function () { - $builder = new Builder('Test'); - $builder->string('name'); - $builder->additionalProperties(false); + $property = new Property('Test'); + $property->string('name'); + $property->additionalProperties(false); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('additionalProperties', false); }); it('can use strict() helper for disallowing additional properties', function () { - $builder = new Builder('Test'); - $builder->string('name'); - $builder->strict(); + $property = new Property('Test'); + $property->string('name'); + $property->strict(); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('additionalProperties', false); }); it('strict() makes all fields required', function () { - $builder = new Builder('Test'); - $builder->string('name'); - $builder->email('email'); - $builder->int('age')->min(0); - $builder->strict(); + $property = new Property('Test'); + $property->string('name'); + $property->email('email'); + $property->int('age')->min(0); + $property->strict(); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('additionalProperties', false) ->and($schema['required'])->toBe(['name', 'email', 'age']); }); it('defaults additionalProperties to false', function () { - $builder = new Builder('Test'); - $builder->string('name'); + $property = new Property('Test'); + $property->string('name'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('additionalProperties', false); }); @@ -61,28 +62,28 @@ describe('uniqueItems', function () { it('can set uniqueItems on array fields', function () { - $builder = new Builder('Test'); - $builder->array('tags')->items('string')->uniqueItems(); + $property = new Property('Test'); + $property->array('tags')->items('string')->uniqueItems(); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['tags'])->toHaveKey('uniqueItems', true); }); it('can explicitly set uniqueItems to false', function () { - $builder = new Builder('Test'); - $builder->array('tags')->items('string')->uniqueItems(false); + $property = new Property('Test'); + $property->array('tags')->items('string')->uniqueItems(false); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['tags'])->toHaveKey('uniqueItems', false); }); it('does not include uniqueItems when not set', function () { - $builder = new Builder('Test'); - $builder->array('tags')->items('string'); + $property = new Property('Test'); + $property->array('tags')->items('string'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['tags'])->not->toHaveKey('uniqueItems'); }); @@ -90,10 +91,10 @@ describe('format', function () { it('can set format on string fields', function () { - $builder = new Builder('Test'); - $builder->string('email')->format('email'); + $property = new Property('Test'); + $property->string('email')->format('email'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['email'])->toHaveKey('format', 'email'); }); @@ -177,30 +178,30 @@ describe('schema version', function () { it('can set JSON Schema version', function () { - $builder = new Builder('Test'); - $builder->string('name'); - $builder->schemaVersion('https://json-schema.org/draft/2020-12/schema'); + $property = new Property('Test'); + $property->string('name'); + $property->schemaVersion('https://json-schema.org/draft/2020-12/schema'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); }); it('uses default version when called without arguments', function () { - $builder = new Builder('Test'); - $builder->string('name'); - $builder->schemaVersion(); + $property = new Property('Test'); + $property->string('name'); + $property->schemaVersion(); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('$schema', 'https://json-schema.org/draft/2020-12/schema'); }); it('does not include $schema when not set', function () { - $builder = new Builder('Test'); - $builder->string('name'); + $property = new Property('Test'); + $property->string('name'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->not->toHaveKey('$schema'); }); @@ -208,11 +209,11 @@ describe('title', function () { it('can set title on schema', function () { - $builder = new Builder('Test'); - $builder->title('User Schema'); - $builder->string('name'); + $property = new Property('Test'); + $property->title('User Schema'); + $property->string('name'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('title', 'User Schema'); }); @@ -227,10 +228,10 @@ }); it('does not include title when not set', function () { - $builder = new Builder('Test'); - $builder->string('name'); + $property = new Property('Test'); + $property->string('name'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->not->toHaveKey('title'); }); @@ -238,10 +239,10 @@ describe('helper methods', function () { it('can add email field', function () { - $builder = new Builder('Test'); - $builder->email('email', 'User email address'); + $property = new Property('Test'); + $property->email('email', 'User email address'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['email']['type'])->toBe('string') ->and($schema['properties']['email']['format'])->toBe('email') @@ -249,90 +250,90 @@ }); it('can add url field', function () { - $builder = new Builder('Test'); - $builder->url('website'); + $property = new Property('Test'); + $property->url('website'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['website']['type'])->toBe('string') ->and($schema['properties']['website']['format'])->toBe('uri'); }); it('can add uuid field', function () { - $builder = new Builder('Test'); - $builder->uuid('id'); + $property = new Property('Test'); + $property->uuid('id'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['id']['type'])->toBe('string') ->and($schema['properties']['id']['format'])->toBe('uuid'); }); it('can add datetime field', function () { - $builder = new Builder('Test'); - $builder->datetime('created_at'); + $property = new Property('Test'); + $property->datetime('created_at'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['created_at']['type'])->toBe('string') ->and($schema['properties']['created_at']['format'])->toBe('date-time'); }); it('can add date field', function () { - $builder = new Builder('Test'); - $builder->date('birth_date'); + $property = new Property('Test'); + $property->date('birth_date'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['birth_date']['type'])->toBe('string') ->and($schema['properties']['birth_date']['format'])->toBe('date'); }); it('can add time field', function () { - $builder = new Builder('Test'); - $builder->time('start_time'); + $property = new Property('Test'); + $property->time('start_time'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['start_time']['type'])->toBe('string') ->and($schema['properties']['start_time']['format'])->toBe('time'); }); it('can add ipv4 field', function () { - $builder = new Builder('Test'); - $builder->ipv4('ip_address'); + $property = new Property('Test'); + $property->ipv4('ip_address'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['ip_address']['type'])->toBe('string') ->and($schema['properties']['ip_address']['format'])->toBe('ipv4'); }); it('can add ipv6 field', function () { - $builder = new Builder('Test'); - $builder->ipv6('ipv6_address'); + $property = new Property('Test'); + $property->ipv6('ipv6_address'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['ipv6_address']['type'])->toBe('string') ->and($schema['properties']['ipv6_address']['format'])->toBe('ipv6'); }); it('can add hostname field', function () { - $builder = new Builder('Test'); - $builder->hostname('server'); + $property = new Property('Test'); + $property->hostname('server'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['server']['type'])->toBe('string') ->and($schema['properties']['server']['format'])->toBe('hostname'); }); it('helper methods support chaining', function () { - $builder = new Builder('Test'); - $builder->email('email')->required(); + $property = new Property('Test'); + $property->email('email')->required(); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['email']['format'])->toBe('email') ->and($schema['required'])->toContain('email'); @@ -340,43 +341,66 @@ }); describe('complete schema with all features', function () { - it('generates comprehensive schema with all features', function () { - $schema = Struct::define('CompleteExample', function (Builder $builder) { - $builder->schemaVersion(); - $builder->title('Complete Schema Example'); - $builder->description('A comprehensive schema demonstrating all features'); - $builder->strict(); - - $builder->uuid('id')->required(); - $builder->email('email')->required(); - $builder->url('website')->nullable(); - $builder->datetime('created_at')->required(); - $builder->string('status') + it('generates comprehensive schema with all features in OpenAI format when strict', function () { + $schema = Struct::define('CompleteExample', 'Complete example with all features', function (Property $property) { + $property->schemaVersion(); + $property->title('Complete Schema Example'); + $property->description('A comprehensive schema demonstrating all features'); + $property->strict(); + + $property->uuid('id')->required(); + $property->email('email')->required(); + $property->url('website')->nullable(); + $property->datetime('created_at')->required(); + $property->string('status') ->enum(['active', 'inactive']) ->default('active'); - $builder->array('tags') + $property->array('tags') ->items('string') ->uniqueItems() ->minItems(1) ->maxItems(5); - $builder->object('metadata', function (Builder $nested) { + $property->object('metadata', function (Property $nested) { $nested->string('version')->required(); $nested->int('count')->min(0); })->nullable(); - }); - - expect($schema)->toHaveKey('$schema') - ->and($schema)->toHaveKey('title', 'Complete Schema Example') - ->and($schema)->toHaveKey('description') - ->and($schema)->toHaveKey('additionalProperties', false) + })->toArray(); + + // Check OpenAI format wrapper + expect($schema)->toHaveKey('name', 'CompleteExample') + ->and($schema)->toHaveKey('strict', true) + ->and($schema)->toHaveKey('schema'); + + // Check the nested schema structure + $nestedSchema = $schema['schema']; + expect($nestedSchema)->toHaveKey('$schema') + ->and($nestedSchema)->toHaveKey('title', 'Complete Schema Example') + ->and($nestedSchema)->toHaveKey('description') + ->and($nestedSchema)->toHaveKey('additionalProperties', false) + ->and($nestedSchema['properties']['id']['format'])->toBe('uuid') + ->and($nestedSchema['properties']['email']['format'])->toBe('email') + ->and($nestedSchema['properties']['website']['type'])->toBe(['string', 'null']) + ->and($nestedSchema['properties']['tags']['uniqueItems'])->toBeTrue() + ->and($nestedSchema['properties']['metadata']['type'])->toBe(['object', 'null']) + ->and($nestedSchema['required'])->toContain('id', 'email', 'created_at'); + }); + + it('generates normal schema without strict mode', function () { + $schema = Struct::define('NormalExample', 'Normal example without strict mode', function (Property $property) { + $property->title('Normal Schema Example'); + $property->uuid('id')->required(); + $property->email('email')->required(); + })->toArray(); + + // Without strict(), should return normal flat schema + expect($schema)->toHaveKey('type', 'object') + ->and($schema)->toHaveKey('title', 'Normal Schema Example') + ->and($schema)->not->toHaveKey('name') + ->and($schema)->not->toHaveKey('strict') ->and($schema['properties']['id']['format'])->toBe('uuid') - ->and($schema['properties']['email']['format'])->toBe('email') - ->and($schema['properties']['website']['type'])->toBe(['string', 'null']) - ->and($schema['properties']['tags']['uniqueItems'])->toBeTrue() - ->and($schema['properties']['metadata']['type'])->toBe(['object', 'null']) - ->and($schema['required'])->toContain('id', 'email', 'created_at'); + ->and($schema['properties']['email']['format'])->toBe('email'); }); }); }); diff --git a/tests/Unit/BuilderTest.php b/tests/Unit/BuilderTest.php index ae08bfc..03e8f69 100644 --- a/tests/Unit/BuilderTest.php +++ b/tests/Unit/BuilderTest.php @@ -1,81 +1,82 @@ toBeInstanceOf(Builder::class); + expect($property)->toBeInstanceOf(Property::class); }); it('can add a string property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->string('username'); + $property = new Property('TestSchema'); + $property = $property->string('username'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add an integer property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->int('age'); + $property = new Property('TestSchema'); + $property = $property->int('age'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add an integer property using integer alias', function () { - $builder = new Builder('TestSchema'); - $property = $builder->integer('count'); + $property = new Property('TestSchema'); + $property = $property->integer('count'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add a float property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->float('price'); + $property = new Property('TestSchema'); + $property = $property->float('price'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add a number property using number alias', function () { - $builder = new Builder('TestSchema'); - $property = $builder->number('amount'); + $property = new Property('TestSchema'); + $property = $property->number('amount'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add a boolean property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->boolean('isActive'); + $property = new Property('TestSchema'); + $property = $property->boolean('isActive'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add a boolean property using bool alias', function () { - $builder = new Builder('TestSchema'); - $property = $builder->bool('enabled'); + $property = new Property('TestSchema'); + $property = $property->bool('enabled'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add an array property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->array('tags'); + $property = new Property('TestSchema'); + $property = $property->array('tags'); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add an enum property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->enum('status', ['active', 'inactive', 'pending']); + $property = new Property('TestSchema'); + $property = $property->enum('status', ['active', 'inactive', 'pending']); expect($property)->toBeInstanceOf(PropertyBuilder::class); }); it('can add a nested object property', function () { - $builder = new Builder('TestSchema'); - $property = $builder->object('user', function (Builder $nested) { + $property = new Property('TestSchema'); + $property = $property->object('user', function (Property $nested) { $nested->string('name'); $nested->string('email'); }); @@ -84,18 +85,18 @@ }); it('can set a description on the schema', function () { - $builder = new Builder('TestSchema'); - $result = $builder->description('Test schema description'); + $property = new Property('TestSchema'); + $result = $property->description('Test schema description'); - expect($result)->toBe($builder); + expect($result)->toBe($property); }); it('generates correct JSON schema array for simple properties', function () { - $builder = new Builder('TestSchema'); - $builder->string('name'); - $builder->int('age'); + $property = new Property('TestSchema'); + $property->string('name'); + $property->int('age'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('type', 'object') ->and($schema)->toHaveKey('properties') @@ -106,22 +107,22 @@ }); it('includes description in schema when set', function () { - $builder = new Builder('TestSchema'); - $builder->description('User schema'); - $builder->string('name'); + $property = new Property('TestSchema'); + $property->description('User schema'); + $property->string('name'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('description', 'User schema'); }); it('includes required fields in schema', function () { - $builder = new Builder('TestSchema'); - $builder->string('name')->required(); - $builder->string('email')->required(); - $builder->int('age'); + $property = new Property('TestSchema'); + $property->string('name')->required(); + $property->string('email')->required(); + $property->int('age'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->toHaveKey('required') ->and($schema['required'])->toContain('name') @@ -130,23 +131,23 @@ }); it('does not include required key when no fields are required', function () { - $builder = new Builder('TestSchema'); - $builder->string('name'); - $builder->int('age'); + $property = new Property('TestSchema'); + $property->string('name'); + $property->int('age'); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema)->not->toHaveKey('required'); }); it('generates correct schema for nested objects', function () { - $builder = new Builder('TestSchema'); - $builder->object('user', function (Builder $nested) { + $property = new Property('TestSchema'); + $property->object('user', function (Property $nested) { $nested->string('name'); $nested->string('email'); }); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['user']['type'])->toBe('object') ->and($schema['properties']['user']['properties'])->toHaveKey('name') @@ -154,20 +155,20 @@ }); it('generates correct schema for enum fields', function () { - $builder = new Builder('TestSchema'); - $builder->enum('status', ['active', 'inactive']); + $property = new Property('TestSchema'); + $property->enum('status', ['active', 'inactive']); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['status']['type'])->toBe('string') ->and($schema['properties']['status']['enum'])->toBe(['active', 'inactive']); }); it('can convert schema to JSON string', function () { - $builder = new Builder('TestSchema'); - $builder->string('name')->required(); + $property = new Property('TestSchema'); + $property->string('name')->required(); - $json = $builder->toJson(); + $json = $property->toJson(); expect($json)->toBeString() ->and($json)->toContain('"type": "object"') @@ -176,13 +177,13 @@ }); it('supports method chaining for properties', function () { - $builder = new Builder('TestSchema'); - $builder->string('username') + $property = new Property('TestSchema'); + $property->string('username') ->required() ->minLength(3) ->maxLength(50); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties']['username'])->toHaveKey('minLength', 3) ->and($schema['properties']['username'])->toHaveKey('maxLength', 50) @@ -190,14 +191,14 @@ }); it('can handle multiple properties with mixed types', function () { - $builder = new Builder('TestSchema'); - $builder->string('name')->required(); - $builder->int('age')->min(0)->max(150); - $builder->boolean('isActive')->default(true); - $builder->array('tags'); - $builder->enum('role', ['admin', 'user', 'guest']); + $property = new Property('TestSchema'); + $property->string('name')->required(); + $property->int('age')->min(0)->max(150); + $property->boolean('isActive')->default(true); + $property->array('tags'); + $property->enum('role', ['admin', 'user', 'guest']); - $schema = $builder->toArray(); + $schema = $property->toArray(); expect($schema['properties'])->toHaveCount(5) ->and($schema['properties']['name']['type'])->toBe('string') @@ -208,16 +209,16 @@ }); it('avoids duplicate required fields', function () { - $builder = new Builder('TestSchema'); - $builder->string('name')->required(); + $property = new Property('TestSchema'); + $property->string('name')->required(); - // Manually mark as required again - $builder->markRequired('name'); - $builder->markRequired('name'); + // Calling toArray() multiple times should not duplicate required fields + $schema1 = $property->toArray(); + $schema2 = $property->toArray(); - $schema = $builder->toArray(); - - expect($schema['required'])->toHaveCount(1) - ->and($schema['required'])->toContain('name'); + expect($schema1['required'])->toHaveCount(1) + ->and($schema1['required'])->toContain('name') + ->and($schema2['required'])->toHaveCount(1) + ->and($schema2['required'])->toContain('name'); }); }); diff --git a/tests/Unit/PropertyBuilderTest.php b/tests/Unit/PropertyBuilderTest.php index 44db260..db8dfa2 100644 --- a/tests/Unit/PropertyBuilderTest.php +++ b/tests/Unit/PropertyBuilderTest.php @@ -1,7 +1,8 @@ items('object', function (Builder $builder) { - $builder->string('name'); - $builder->string('email'); + $result = $property->items('object', function (Property $property) { + $property->string('name'); + $property->string('email'); }); expect($result)->toBe($property); @@ -169,7 +170,7 @@ }); it('can set nested builder for objects', function () { - $builder = new Builder('nested'); + $builder = new Property('nested'); $builder->string('field'); $property = new PropertyBuilder('data', 'object'); @@ -250,7 +251,7 @@ }); it('merges nested builder schema correctly', function () { - $builder = new Builder('user'); + $builder = new Property('user'); $builder->string('name')->required(); $builder->string('email')->required(); diff --git a/tests/Unit/StructTest.php b/tests/Unit/StructTest.php index 4ab48f9..d3d25b8 100644 --- a/tests/Unit/StructTest.php +++ b/tests/Unit/StructTest.php @@ -1,13 +1,14 @@ string('name'); - $builder->string('email'); + $struct = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('name'); + $property->string('email'); }); expect($struct)->toBeInstanceOf(Struct::class); @@ -20,46 +21,33 @@ }); it('returns a Struct instance from define method', function () { - $struct = Struct::define('Simple', function (Builder $builder) { - $builder->string('field'); + $struct = Struct::define('Simple', 'A simple schema', function (Property $property) { + $property->string('field'); }); expect($struct)->toBeInstanceOf(Struct::class); }); it('can define schema with required fields', function () { - $schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); - $builder->string('email')->required(); - $builder->int('age'); - }); + $schema = Struct::define('User', 'A user with required fields', function (Property $property) { + $property->string('name')->required(); + $property->string('email')->required(); + $property->int('age'); + })->toArray(); - // Can be used like an array expect($schema['required'])->toContain('name') ->and($schema['required'])->toContain('email') ->and($schema['required'])->not->toContain('age'); }); - it('can access struct as array via ArrayAccess', function () { - $schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); - }); - - // Array access works - expect(isset($schema['type']))->toBeTrue() - ->and($schema['type'])->toBe('object') - ->and(isset($schema['properties']))->toBeTrue() - ->and(isset($schema['properties']['name']))->toBeTrue(); - }); - it('can define schema with nested objects', function () { - $schema = Struct::define('Post', function (Builder $builder) { - $builder->string('title')->required(); - $builder->object('author', function (Builder $nested) { + $schema = Struct::define('Post', 'A blog post schema', function (Property $property) { + $property->string('title')->required(); + $property->object('author', function (Property $nested) { $nested->string('name')->required(); $nested->string('email')->required(); })->required(); - }); + })->toArray(); expect($schema['properties'])->toHaveKey('author') ->and($schema['properties']['author']['type'])->toBe('object') @@ -70,10 +58,10 @@ }); it('can define schema with array fields', function () { - $schema = Struct::define('Product', function (Builder $builder) { - $builder->string('name'); - $builder->array('tags')->items('string'); - }); + $schema = Struct::define('Product', 'A product schema', function (Property $property) { + $property->string('name'); + $property->array('tags')->items('string'); + })->toArray(); expect($schema['properties'])->toHaveKey('tags') ->and($schema['properties']['tags']['type'])->toBe('array') @@ -81,10 +69,10 @@ }); it('can define schema with enum fields', function () { - $schema = Struct::define('User', function (Builder $builder) { - $builder->string('name'); - $builder->enum('role', ['admin', 'user', 'guest']); - }); + $schema = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('name'); + $property->enum('role', ['admin', 'user', 'guest']); + })->toArray(); expect($schema['properties'])->toHaveKey('role') ->and($schema['properties']['role']['type'])->toBe('string') @@ -92,13 +80,13 @@ }); it('can define schema with all primitive types', function () { - $schema = Struct::define('AllTypes', function (Builder $builder) { - $builder->string('text'); - $builder->int('count'); - $builder->float('price'); - $builder->boolean('isActive'); - $builder->array('items'); - }); + $schema = Struct::define('AllTypes', 'Schema with all types', function (Property $property) { + $property->string('text'); + $property->int('count'); + $property->float('price'); + $property->boolean('isActive'); + $property->array('items'); + })->toArray(); expect($schema['properties']['text']['type'])->toBe('string') ->and($schema['properties']['count']['type'])->toBe('integer') @@ -108,15 +96,15 @@ }); it('can define schema with property constraints', function () { - $schema = Struct::define('User', function (Builder $builder) { - $builder->string('username') + $schema = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('username') ->required() ->minLength(3) ->maxLength(50); - $builder->int('age') + $property->int('age') ->min(0) ->max(150); - }); + })->toArray(); expect($schema['properties']['username'])->toHaveKey('minLength', 3) ->and($schema['properties']['username'])->toHaveKey('maxLength', 50) @@ -125,31 +113,31 @@ }); it('can define schema with description', function () { - $schema = Struct::define('User', function (Builder $builder) { - $builder->description('A user object'); - $builder->string('name'); - }); + $schema = Struct::define('User', 'A user schema', function (Property $property) { + $property->description('A user object'); + $property->string('name'); + })->toArray(); expect($schema)->toHaveKey('description', 'A user object'); }); it('can define complex nested schema', function () { - $schema = Struct::define('BlogPost', function (Builder $builder) { - $builder->string('title')->required(); - $builder->string('content')->required(); - $builder->object('author', function (Builder $author) { + $schema = Struct::define('BlogPost', 'A blog post with comments', function (Property $property) { + $property->string('title')->required(); + $property->string('content')->required(); + $property->object('author', function (Property $author) { $author->string('name')->required(); $author->string('email')->required(); $author->string('bio'); })->required(); - $builder->array('comments')->items('object', function (Builder $comment) { + $property->array('comments')->items('object', function (Property $comment) { $comment->string('text')->required(); $comment->string('authorName')->required(); $comment->int('timestamp')->required(); }); - $builder->array('tags')->items('string'); - $builder->enum('status', ['draft', 'published', 'archived'])->default('draft'); - }); + $property->array('tags')->items('string'); + $property->enum('status', ['draft', 'published', 'archived'])->default('draft'); + })->toArray(); expect($schema['type'])->toBe('object') ->and($schema['properties'])->toHaveKey('title') @@ -165,9 +153,9 @@ }); it('can define empty schema', function () { - $schema = Struct::define('Empty', function (Builder $builder) { + $schema = Struct::define('Empty', 'An empty schema', function (Property $property) { // No properties - }); + })->toArray(); expect($schema['type'])->toBe('object') ->and($schema['properties'])->toBeArray() @@ -176,11 +164,11 @@ }); it('can define schema with default values', function () { - $schema = Struct::define('Settings', function (Builder $builder) { - $builder->boolean('notifications')->default(true); - $builder->string('theme')->default('light'); - $builder->int('pageSize')->default(10); - }); + $schema = Struct::define('Settings', 'Application settings', function (Property $property) { + $property->boolean('notifications')->default(true); + $property->string('theme')->default('light'); + $property->int('pageSize')->default(10); + })->toArray(); expect($schema['properties']['notifications']['default'])->toBe(true) ->and($schema['properties']['theme']['default'])->toBe('light') @@ -188,27 +176,27 @@ }); it('can define schema with pattern validation', function () { - $schema = Struct::define('Contact', function (Builder $builder) { - $builder->string('email')->pattern('^[^@]+@[^@]+\.[^@]+$'); - $builder->string('phone')->pattern('^\d{3}-\d{3}-\d{4}$'); - }); + $schema = Struct::define('Contact', 'Contact information', function (Property $property) { + $property->string('email')->pattern('^[^@]+@[^@]+\.[^@]+$'); + $property->string('phone')->pattern('^\d{3}-\d{3}-\d{4}$'); + })->toArray(); expect($schema['properties']['email'])->toHaveKey('pattern') ->and($schema['properties']['phone'])->toHaveKey('pattern'); }); it('can define schema with deeply nested objects', function () { - $schema = Struct::define('Company', function (Builder $builder) { - $builder->string('name'); - $builder->object('address', function (Builder $address) { + $schema = Struct::define('Company', 'Company information', function (Property $property) { + $property->string('name'); + $property->object('address', function (Property $address) { $address->string('street'); $address->string('city'); - $address->object('coordinates', function (Builder $coords) { + $address->object('coordinates', function (Property $coords) { $coords->float('latitude'); $coords->float('longitude'); }); }); - }); + })->toArray(); expect($schema['properties']['address']['type'])->toBe('object') ->and($schema['properties']['address']['properties'])->toHaveKey('coordinates') @@ -218,41 +206,21 @@ }); it('can define schema with array constraints', function () { - $schema = Struct::define('List', function (Builder $builder) { - $builder->array('items') + $schema = Struct::define('List', 'A list of items', function (Property $property) { + $property->array('items') ->minItems(1) ->maxItems(10) ->items('string'); - }); + })->toArray(); expect($schema['properties']['items'])->toHaveKey('minItems', 1) ->and($schema['properties']['items'])->toHaveKey('maxItems', 10) ->and($schema['properties']['items']['items'])->toBe(['type' => 'string']); }); - it('can generate JSON string directly with toJson method', function () { - $json = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); - $builder->email('email')->required(); - })->toJson(); - - expect($json)->toBeString() - ->and($json)->toContain('"type": "object"') - ->and($json)->toContain('"name"') - ->and($json)->toContain('"email"') - ->and($json)->toContain('"format": "email"') - ->and($json)->toContain('"required"'); - - $decoded = json_decode($json, true); - expect($decoded)->toBeArray() - ->and($decoded['type'])->toBe('object') - ->and($decoded['properties'])->toHaveKey('name') - ->and($decoded['properties'])->toHaveKey('email'); - }); - it('can be serialized with json_encode', function () { - $schema = Struct::define('User', function (Builder $builder) { - $builder->string('name')->required(); + $schema = Struct::define('User', 'A user schema', function (Property $property) { + $property->string('name')->required(); }); $json = json_encode($schema, JSON_PRETTY_PRINT);