commit 325569b7a56f2bebeec82c65259d74281c3460ef Author: Andrew Date: Thu Oct 8 21:52:11 2026 +0300 Initial release: JsonFicator v1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2c5f52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/vendor/ +/composer.lock +/.phpunit.result.cache +/.phpunit.cache/ +/.idea/ +/.vscode/ +/coverage/ +*.swp +*.swo +*~ +.DS_Store diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..bcb70e4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,168 @@ +# JsonFicator Architecture + +## Overview + +JsonFicator is a pipeline-based library that transforms unstructured natural language into structured data. The pipeline is: + +``` +Input text + → Schema resolution (Array or Class) + → JSON Schema conversion (for OpenAI strict mode) + → Prompt building (system + user messages) + → LLM generation (OpenAIProvider) + → Response parsing (strip markdown, decode JSON) + → Validation (check types, required fields, unexpected keys) + → Hydration (array → PHP model) + → JsonFicatorResult +``` + +## Core Components + +### JsonFicator (Facade) + +Entry point. Delegates to the pipeline: + +- `toJson(string $input, array|class-string $schema, ?JsonFicatorOptions $options): JsonFicatorResult` — returns array result +- `toModel(string $input, class-string $schema, ?JsonFicatorOptions $options): JsonFicatorResult` — returns hydrated model + +### Schema Layer + +Internal representation is decoupled from any LLM provider. + +#### JsonFicatorSchema + +- `fields: SchemaField[]` +- Optional `modelClass: class-string` for class-based schemas + +#### SchemaField + +- `name: string` +- `type: string` — primitive (`string`, `int`, `float`, `bool`) or `array` or `object` +- `nullable: bool` +- `required: bool` +- `description: ?string` +- `properties: ?SchemaField[]` — for nested objects +- `items: ?SchemaField` — for typed arrays +- `class: ?class-string` — for nested model classes + +#### Resolvers + +- `ArraySchemaResolver` — parses inline array syntax: + - `string`, `int`, `float`, `bool` + - `?string` for nullable + - `array`, `array`, `array`, `array` +- `ClassSchemaResolver` — uses PHP reflection: + - Supports primitives, nullable, arrays, nested `IJsonFicatorModel` classes + - Reads `JsonFicatorDescription`, `JsonFicatorRequired`, `JsonFicatorOptional` attributes + +#### JsonSchemaConverter + +Converts internal `JsonFicatorSchema` to OpenAI-compatible JSON Schema: + +- `type: object` with `properties` +- `required` array based on `SchemaField.required` +- Nullable fields use `anyOf: [{type}, {type: "null"}]` +- Arrays use `items` schema +- Nested objects recurse into `properties` +- `additionalProperties: false` when `strict: true` + +### Provider Layer + +#### LLMProviderInterface + +```php +public function generate( + string $input, + JsonFicatorSchema $schema, + ?JsonFicatorOptions $options = null, +): array; +``` + +Returns raw decoded associative array from LLM. + +#### OpenAIProvider + +- Uses `openai-php/client` via `new \OpenAI\Factory()` (avoids global `\OpenAI` class collision) +- Builds `ChatCompletion` with `ResponseFormat::createJsonSchema(..., strict: true)` +- Normalizes all exceptions to `ProviderException` +- Extracts `choices[0].message.content` and decodes JSON + +### Response Pipeline + +#### ResponseParser + +- Accepts `array` (already decoded) or `string` (raw JSON) +- Strips markdown code fences (```json ... ```) +- Decodes JSON string to associative array +- Throws `InvalidResponseException` if result is not an object (associative array) + +#### Validator + +Validates parsed data against internal `JsonFicatorSchema`: + +- Required fields present +- Nullable rules respected +- Primitive type checks (`string`, `int`, `float`, `bool`) +- Array items validated recursively +- Nested objects validated against sub-schema +- Unexpected fields rejected when schema has `modelClass` (class-based) or strict mode + +#### ModelHydrator + +Hydrates validated array into PHP model via reflection: + +- Creates instance without constructor +- Sets public properties directly +- Handles nullable, nested models, arrays +- Supports union types (picks first matching type) +- Casts `int` → `string` when needed +- Throws `ModelHydrationException` on type mismatch + +### Options & Result + +#### JsonFicatorOptions + +Immutable per-call configuration: + +- `model: ?string` +- `temperature: ?float` +- `maxTokens: ?int` + +Methods: `withModel()`, `withTemperature()`, `withMaxTokens()` + +#### JsonFicatorResult + +Immutable wrapper: + +- `toArray(): array` +- `toJson(): string` +- `toModel(): object` (requires schema was a class) +- `get(string $key, mixed $default = null): mixed` +- `getSchema(): JsonFicatorSchema` + +## Exception Hierarchy + +``` +JsonFicatorException (base) +├── SchemaException +├── ProviderException +├── InvalidResponseException +└── ModelHydrationException +``` + +All extend `\Exception` and are located in `src/Exception/`. + +## Design Decisions + +1. **Internal schema representation** — decoupled from OpenAI JSON Schema, allowing future providers (Claude, Gemini) to reuse the same validators and hydrators. +2. **Strict mode by default** — `additionalProperties: false` prevents LLM hallucination of extra fields. +3. **Readonly properties** — all service classes use `private readonly` for immutability. +4. **No constructor in models** — hydrator uses `newInstanceWithoutConstructor()` to support plain DTOs. +5. **Attributes over docblocks** — PHP 8 attributes are native, cacheable, and type-safe. +6. **Factory over global class** — `new \OpenAI\Factory()` avoids fatal error when `vendor/openai-php/client/src/OpenAI.php` (global class) is loaded via Composer `files` autoload. + +## Testing Strategy + +- **Unit tests** — 76 tests covering every component in isolation with fake provider +- **Integration tests** — gated by `JSONFICATOR_OPENAI_API_KEY` env var; tests real API with small model (`gpt-4o-mini`) +- **Fixtures** — `tests/Fixtures/` contains sample models (`VinRequest`, `Car`, `NestedRequest`, `DescribedModel`, `FakeProvider`) diff --git a/README.md b/README.md new file mode 100644 index 0000000..be7a140 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# JsonFicator + +Framework-agnostic PHP library that converts natural language into structured data (arrays or PHP models) according to a schema, using LLM providers (OpenAI-compatible). + +## Features + +- **Schema-driven extraction** — define the shape of output via PHP arrays or classes +- **Two schema resolvers** — `ArraySchemaResolver` for inline syntax, `ClassSchemaResolver` for reflection-based schemas +- **OpenAI strict structured outputs** — generates JSON Schema with `additionalProperties: false` for deterministic results +- **PHP 8 Attributes** — mark properties with `#[JsonFicatorDescription]`, `#[JsonFicatorRequired]`, `#[JsonFicatorOptional]` +- **Immutable result** — `JsonFicatorResult` with `toArray()`, `toJson()`, `toModel()` +- **Per-call options** — `JsonFicatorOptions` with `withModel()`, `withTemperature()`, `withMaxTokens()` +- **Extensible** — swap LLM provider via `LLMProviderInterface` +- **Modern PHP** — `declare(strict_types=1)`, readonly properties, named arguments, PHP 8.2+ + +## Installation + +```bash +composer require jsonficator/jsonficator +``` + +You also need a PSR-18 HTTP client (e.g. Guzzle) in your project: + +```bash +composer require guzzlehttp/guzzle +``` + +## Quick Start + +### Array schema + +```php + 'sk-...']) +); + +$result = $jsonFicator->toJson( + 'My car is a red Toyota Camry from 2020.', + ['brand' => 'string', 'model' => 'string', 'year' => 'int', 'color' => '?string'] +); + +print_r($result->toArray()); +// ['brand' => 'Toyota', 'model' => 'Camry', 'year' => 2020, 'color' => 'red'] +``` + +### PHP model schema + +```php + 'sk-...']) +); + +$result = $jsonFicator->toModel( + 'My car is a red Toyota Camry from 2020.', + Car::class +); + +$car = $result->toModel(); // instance of Car +``` + +### Nested models + +```php +toModel( + 'John owns a Ford. His phone is +1-555-1234.', + Car::class +); +``` + +## Configuration + +### OpenAIProvider + +```php +$provider = new OpenAIProvider([ + 'host' => 'https://api.openai.com/v1', // or any OpenAI-compatible endpoint + 'key' => 'sk-...', // or set OPENAI_API_KEY env var + 'model' => 'gpt-4o', + 'strict' => true, // JSON Schema strict mode +]); +``` + +### Per-call options + +```php +use JsonFicator\JsonFicatorOptions; + +$options = (new JsonFicatorOptions()) + ->withModel('gpt-4o-mini') + ->withTemperature(0.1) + ->withMaxTokens(512); + +$result = $jsonFicator->toJson($text, $schema, $options); +``` + +## Schema Syntax (Array) + +| Syntax | Meaning | +|---------------|----------------------------------| +| `string` | Non-nullable string | +| `?string` | Nullable string | +| `int` | Integer | +| `float` | Float / double | +| `bool` | Boolean | +| `array` | Untyped array | +| `array` | Array of strings | +| `array` | Array of integers | +| `array`| Nested array of objects | + +## Attributes + +- `#[JsonFicatorDescription('...')]` — adds description to the JSON Schema field +- `#[JsonFicatorRequired]` — marks property as required (default for non-nullable) +- `#[JsonFicatorOptional]` — marks property as optional even if type is non-nullable + +## Testing + +```bash +# Unit tests only +composer run test:unit + +# Integration tests (requires JSONFICATOR_OPENAI_API_KEY) +composer run test:integration + +# All tests +composer run test +``` + +## Architecture + +See [ARCHITECTURE.md](ARCHITECTURE.md) for design decisions and internal pipeline. + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ceb2043 --- /dev/null +++ b/composer.json @@ -0,0 +1,54 @@ +{ + "name": "jsonficator/jsonficator", + "description": "Framework-agnostic PHP library that converts natural language into structured data (arrays or PHP models) according to a schema, using LLM providers (OpenAI-compatible).", + "type": "library", + "license": "MIT", + "keywords": [ + "json", + "llm", + "openai", + "structured-output", + "extraction", + "nlp", + "schema" + ], + "authors": [ + { + "name": "geckon01" + } + ], + "homepage": "https://github.com/geckon01/JsonFicatorPHP", + "support": { + "issues": "https://github.com/geckon01/JsonFicatorPHP/issues" + }, + "require": { + "php": "^8.2", + "ext-json": "*", + "openai-php/client": "^0.21.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^8.2", + "phpunit/phpunit": "^11.0 || ^12.0 || ^13.0" + }, + "autoload": { + "psr-4": { + "JsonFicator\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "JsonFicator\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test:integration": "phpunit --group integration", + "test:unit": "phpunit --exclude-group integration" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..c204994 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + tests/Unit + + + tests/Integration + + + + + src + + + \ No newline at end of file diff --git a/src/Attribute/JsonFicatorDescription.php b/src/Attribute/JsonFicatorDescription.php new file mode 100644 index 0000000..c1b5f22 --- /dev/null +++ b/src/Attribute/JsonFicatorDescription.php @@ -0,0 +1,28 @@ +validationErrors; + } +} \ No newline at end of file diff --git a/src/Exception/JsonFicatorException.php b/src/Exception/JsonFicatorException.php new file mode 100644 index 0000000..de46bee --- /dev/null +++ b/src/Exception/JsonFicatorException.php @@ -0,0 +1,16 @@ + 'https://api.openai.com/v1', + * 'key' => 'sk-...', + * 'model' => 'gpt-4o', + * ])); + * + * // Array schema + * $result = $jsonFictator->toJson('Колодки на ниссан лиф +799999999', [ + * 'carBrand' => 'string?', + * 'carModel' => 'string?', + * 'phoneNumber' => 'string', + * ]); + * + * // Class schema + * $result = $jsonFictator->toModel('Колодки на ниссан лиф +799999999', VinRequest::class); + * ``` + */ +final class JsonFicator +{ + private readonly LLMProviderInterface $provider; + private readonly ResponseParser $parser; + private readonly Validator $validator; + private readonly ArraySchemaResolver $arrayResolver; + private readonly ClassSchemaResolver $classResolver; + + /** + * @param LLMProviderInterface $provider The LLM provider to use. + */ + public function __construct( + LLMProviderInterface $provider, + ) { + $this->provider = $provider; + $this->parser = new ResponseParser(); + $this->validator = new Validator(); + $this->arrayResolver = new ArraySchemaResolver(); + $this->classResolver = new ClassSchemaResolver(); + } + + /** + * Extract structured data from text using an array schema. + * + * @param string $input The raw user text. + * @param array $schema The array-syntax schema. + * @param JsonFicatorOptions|null $options Per-call options. + * + * @return JsonFicatorResult + * + * @throws SchemaException + * @throws \JsonFicator\Exception\ProviderException + * @throws \JsonFicator\Exception\InvalidResponseException + */ + public function toJson( + string $input, + array $schema, + ?JsonFicatorOptions $options = null, + ): JsonFicatorResult { + $internalSchema = $this->arrayResolver->resolve($schema); + + return $this->extract($input, $internalSchema, $options); + } + + /** + * Extract structured data from text using a class schema. + * + * @template T of IJsonFicatorModel + * @param string $input The raw user text. + * @param class-string $class The model class. + * @param JsonFicatorOptions|null $options Per-call options. + * + * @return T + * + * @throws SchemaException + * @throws \JsonFicator\Exception\ProviderException + * @throws \JsonFicator\Exception\InvalidResponseException + * @throws \JsonFicator\Exception\ModelHydrationException + */ + public function toModel( + string $input, + string $class, + ?JsonFicatorOptions $options = null, + ): object { + $internalSchema = $this->classResolver->resolve($class); + $result = $this->extract($input, $internalSchema, $options); + + return $result->toModel($class); + } + + /** + * Core extraction pipeline. + * + * @param string $input + * @param JsonFicatorSchema $schema + * @param JsonFicatorOptions|null $options + * + * @return JsonFicatorResult + */ + private function extract( + string $input, + JsonFicatorSchema $schema, + ?JsonFicatorOptions $options = null, + ): JsonFicatorResult { + // 1. Generate via provider. + $rawResponse = $this->provider->generate($input, $schema, $options); + + // 2. Parse. + $data = $this->parser->parse($rawResponse); + + // 3. Validate. + $this->validator->validate($data, $schema); + + // 4. Return result. + return new JsonFicatorResult($data, $schema); + } +} \ No newline at end of file diff --git a/src/JsonFicatorOptions.php b/src/JsonFicatorOptions.php new file mode 100644 index 0000000..ba94531 --- /dev/null +++ b/src/JsonFicatorOptions.php @@ -0,0 +1,60 @@ + $providerOptions Provider-specific options (e.g. OpenAI-specific flags). + */ + public function __construct( + public readonly ?float $temperature = null, + public readonly ?int $maxTokens = null, + public readonly ?string $systemPrompt = null, + public readonly ?int $timeout = null, + public readonly ?string $model = null, + public readonly array $providerOptions = [], + ) { + } + + /** + * Create a new options object with one field changed (immutable). + */ + public function withSystemPrompt(?string $systemPrompt): self + { + return new self( + temperature: $this->temperature, + maxTokens: $this->maxTokens, + systemPrompt: $systemPrompt, + timeout: $this->timeout, + model: $this->model, + providerOptions: $this->providerOptions, + ); + } + + public function withModel(?string $model): self + { + return new self( + temperature: $this->temperature, + maxTokens: $this->maxTokens, + systemPrompt: $this->systemPrompt, + timeout: $this->timeout, + model: $model, + providerOptions: $this->providerOptions, + ); + } +} \ No newline at end of file diff --git a/src/JsonFicatorResult.php b/src/JsonFicatorResult.php new file mode 100644 index 0000000..2109412 --- /dev/null +++ b/src/JsonFicatorResult.php @@ -0,0 +1,87 @@ + $data The validated array. + * @param JsonFicatorSchema $schema The schema the data was validated against. + */ + public function __construct( + private readonly array $data, + private readonly JsonFicatorSchema $schema, + ) { + $this->hydrator = new ModelHydrator(); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * @param int $options json_encode options. + */ + public function toJson(int $options = 0): string + { + $json = json_encode($this->data, $options | JSON_UNESCAPED_UNICODE); + + if ($json === false) { + throw new \RuntimeException('Failed to encode result as JSON: ' . json_last_error_msg()); + } + + return $json; + } + + /** + * Hydrate the result into a PHP model. + * + * @template T of object + * @param class-string $class + * @return T + * + * @throws ModelHydrationException + */ + public function toModel(string $class): object + { + return $this->hydrator->hydrate($this->data, $class); + } + + /** + * @return JsonFicatorSchema + */ + public function getSchema(): JsonFicatorSchema + { + return $this->schema; + } + + /** + * @param string $key + */ + public function get(string $key): mixed + { + return $this->data[$key] ?? null; + } +} \ No newline at end of file diff --git a/src/Prompt/PromptBuilder.php b/src/Prompt/PromptBuilder.php new file mode 100644 index 0000000..4a776b9 --- /dev/null +++ b/src/Prompt/PromptBuilder.php @@ -0,0 +1,70 @@ +systemPrompt ?? self::DEFAULT_TEMPLATE; + + return str_replace( + '{schema}', + $this->renderSchema($jsonSchema), + $template, + ); + } + + /** + * Build the user message wrapping the raw input. + */ + public function buildUserMessage(string $input): string + { + return "Extract structured data from the following text:\n\n" . $input; + } + + private function renderSchema(array $jsonSchema): string + { + $json = json_encode($jsonSchema, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + return $json === false ? '{}' : $json; + } + + private const DEFAULT_TEMPLATE = <<<'PROMPT' +You are a data extraction assistant. Your task is to extract structured data from the user's text according to the provided JSON Schema. + +Rules: +1. Extract only values that are explicitly present or unambiguously implied in the text. +2. Do NOT invent, guess, or hallucinate values. If a value is not present in the text and the field is nullable, set it to null. +3. If a value cannot be determined with confidence, set it to null (if nullable) or omit it. +4. Follow the JSON Schema exactly. Do not add fields that are not in the schema. +5. Return ONLY a valid JSON object. No markdown, no explanations, no code fences. + +JSON Schema: +{schema} +PROMPT; +} \ No newline at end of file diff --git a/src/Provider/LLMProviderInterface.php b/src/Provider/LLMProviderInterface.php new file mode 100644 index 0000000..1857749 --- /dev/null +++ b/src/Provider/LLMProviderInterface.php @@ -0,0 +1,44 @@ + The decoded JSON object returned by the LLM. + * + * @throws \JsonFicator\Exception\ProviderException If the provider fails. + */ + public function generate( + string $input, + JsonFicatorSchema $schema, + ?JsonFicatorOptions $options = null, + ): array; +} \ No newline at end of file diff --git a/src/Provider/OpenAIProvider.php b/src/Provider/OpenAIProvider.php new file mode 100644 index 0000000..ed2888d --- /dev/null +++ b/src/Provider/OpenAIProvider.php @@ -0,0 +1,136 @@ + 'https://api.openai.com/v1', + * 'key' => 'sk-...', + * 'model' => 'gpt-4o', + * ]); + * ``` + */ +final class OpenAIProvider implements LLMProviderInterface +{ + private readonly Client $client; + private readonly string $defaultModel; + private readonly PromptBuilder $promptBuilder; + private readonly JsonSchemaConverter $schemaConverter; + + /** + * @param array{ + * host?: string, + * key?: string, + * model?: string, + * promptBuilder?: PromptBuilder, + * strict?: bool, + * } $config + */ + public function __construct( + array $config = [], + ) { + $host = $config['host'] ?? 'https://api.openai.com/v1'; + $key = $config['key'] ?? ($_SERVER['OPENAI_API_KEY'] ?? null); + + if ($key === null || $key === '') { + throw new ProviderException( + 'OpenAI API key is required. Provide it via the "key" config option or the OPENAI_API_KEY environment variable.' + ); + } + + $this->defaultModel = $config['model'] ?? 'gpt-4o'; + $this->promptBuilder = $config['promptBuilder'] ?? new PromptBuilder(); + $this->schemaConverter = new JsonSchemaConverter(strict: $config['strict'] ?? true); + + $this->client = (new \OpenAI\Factory()) + ->withApiKey($key) + ->withBaseUri($host) + ->make(); + } + + public function generate( + string $input, + JsonFicatorSchema $schema, + ?JsonFicatorOptions $options = null, + ): array { + $jsonSchema = $this->schemaConverter->convert($schema); + $systemPrompt = $this->promptBuilder->buildSystemPrompt($schema, $jsonSchema); + $userMessage = $this->promptBuilder->buildUserMessage($input); + + $model = $options?->model ?? $this->defaultModel; + + try { + $response = $this->client->chat()->create( + ChatCompletion::create( + model: $model, + messages: [ + ChatMessage::createSystem($systemPrompt), + ChatMessage::createUser($userMessage), + ], + responseFormat: ResponseFormat::createJsonSchema( + name: 'extraction', + schema: $jsonSchema, + strict: true, + ), + temperature: $options?->temperature, + maxTokens: $options?->maxTokens, + ), + ); + } catch (\Throwable $e) { + throw new ProviderException( + 'OpenAI API request failed: ' . $e->getMessage(), + previous: $e, + ); + } + + return $this->extractContent($response); + } + + /** + * @return array + */ + private function extractContent(ChatResponse $response): array + { + $message = $response->choices[0]->message; + + if ($message->content === null || $message->content === '') { + throw new ProviderException( + 'OpenAI returned an empty response. The model may not support structured outputs.' + ); + } + + $decoded = json_decode($message->content, true); + + if (!is_array($decoded)) { + throw new ProviderException( + 'Failed to decode OpenAI response as JSON: ' . substr($message->content, 0, 200) + ); + } + + return $decoded; + } +} \ No newline at end of file diff --git a/src/Response/ModelHydrator.php b/src/Response/ModelHydrator.php new file mode 100644 index 0000000..9281906 --- /dev/null +++ b/src/Response/ModelHydrator.php @@ -0,0 +1,234 @@ + $data + * @param class-string $class + * + * @return object + * + * @throws ModelHydrationException + */ + public function hydrate(array $data, string $class): object + { + if (!class_exists($class)) { + throw new ModelHydrationException("Class '{$class}' does not exist."); + } + + $instance = new $class(); + $reflection = new \ReflectionClass($class); + + foreach ($data as $propertyName => $value) { + if (!$reflection->hasProperty($propertyName)) { + throw new ModelHydrationException( + "Class '{$class}' has no property '{$propertyName}'." + ); + } + + $property = $reflection->getProperty($propertyName); + + if (!$property->isPublic()) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' is not public." + ); + } + + $this->setPropertyValue($instance, $property, $value, $class); + } + + return $instance; + } + + /** + * @param object $instance + * @param \ReflectionProperty $property + * @param mixed $value + * @param class-string $class + * + * @throws ModelHydrationException + */ + private function setPropertyValue( + object $instance, + \ReflectionProperty $property, + mixed $value, + string $class, + ): void { + $propertyName = $property->getName(); + + if ($value === null) { + $property->setValue($instance, null); + return; + } + + $type = $property->getType(); + + if ($type === null) { + // Untyped property — set as-is. + $property->setValue($instance, $value); + return; + } + + if ($type instanceof \ReflectionUnionType) { + $this->setUnionValue($instance, $property, $value, $class); + return; + } + + if (!($type instanceof \ReflectionNamedType)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' has an unsupported type." + ); + } + + $typeName = $type->getName(); + + // Nested model + if (class_exists($typeName) && is_array($value)) { + $nested = $this->hydrate($value, $typeName); + $property->setValue($instance, $nested); + return; + } + + // Array + if ($typeName === 'array' || $typeName === 'list') { + if (!is_array($value)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' expects an array, got " . get_debug_type($value) . '.' + ); + } + $property->setValue($instance, $value); + return; + } + + // Primitives + $this->castAndSet($instance, $property, $value, $typeName, $class); + } + + /** + * @param object $instance + * @param \ReflectionProperty $property + * @param mixed $value + * @param class-string $class + * + * @throws ModelHydrationException + */ + private function setUnionValue( + object $instance, + \ReflectionProperty $property, + mixed $value, + string $class, + ): void { + $propertyName = $property->getName(); + $types = $property->getType(); + + if (!$types instanceof \ReflectionUnionType) { + return; + } + + $nonNullTypes = array_values(array_filter( + $types->getTypes(), + static fn ($t): bool => !($t instanceof \ReflectionNamedType && $t->getName() === 'null'), + )); + + if (count($nonNullTypes) !== 1 || !($nonNullTypes[0] instanceof \ReflectionNamedType)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' has an unsupported union type." + ); + } + + $innerType = $nonNullTypes[0]->getName(); + + if (class_exists($innerType) && is_array($value)) { + $nested = $this->hydrate($value, $innerType); + $property->setValue($instance, $nested); + return; + } + + if ($innerType === 'array' || $innerType === 'list') { + $property->setValue($instance, $value); + return; + } + + $this->castAndSet($instance, $property, $value, $innerType, $class); + } + + /** + * @param object $instance + * @param \ReflectionProperty $property + * @param mixed $value + * @param class-string $class + * + * @throws ModelHydrationException + */ + private function castAndSet( + object $instance, + \ReflectionProperty $property, + mixed $value, + string $typeName, + string $class, + ): void { + $propertyName = $property->getName(); + + switch ($typeName) { + case 'string': + if (!is_string($value) && !is_int($value) && !is_float($value) && !is_bool($value)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' expects a string, got " . get_debug_type($value) . '.' + ); + } + $property->setValue($instance, (string) $value); + break; + + case 'int': + if (!is_int($value) && !is_string($value)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' expects an int, got " . get_debug_type($value) . '.' + ); + } + $property->setValue($instance, (int) $value); + break; + + case 'float': + if (!is_int($value) && !is_float($value) && !is_string($value)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' expects a float, got " . get_debug_type($value) . '.' + ); + } + $property->setValue($instance, (float) $value); + break; + + case 'bool': + if (!is_bool($value) && !is_int($value)) { + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' expects a bool, got " . get_debug_type($value) . '.' + ); + } + $property->setValue($instance, (bool) $value); + break; + + default: + throw new ModelHydrationException( + "Property '{$propertyName}' in class '{$class}' has unsupported type '{$typeName}'." + ); + } + } +} \ No newline at end of file diff --git a/src/Response/ResponseParser.php b/src/Response/ResponseParser.php new file mode 100644 index 0000000..b7c48bc --- /dev/null +++ b/src/Response/ResponseParser.php @@ -0,0 +1,89 @@ + + * + * @throws InvalidResponseException If the response cannot be parsed. + */ + public function parse(mixed $raw): array + { + if (is_array($raw)) { + return $this->ensureObject($raw); + } + + if (is_string($raw)) { + return $this->parseString($raw); + } + + throw new InvalidResponseException( + 'LLM response must be a string or array, got: ' . get_debug_type($raw) + ); + } + + private function parseString(string $raw): array + { + $cleaned = $this->stripCodeFences(trim($raw)); + + $decoded = json_decode($cleaned, true); + + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidResponseException( + 'Failed to parse LLM response as JSON: ' . json_last_error_msg() + . "\nRaw response: " . substr($cleaned, 0, 500) + ); + } + + if (!is_array($decoded)) { + throw new InvalidResponseException( + 'LLM response is not a JSON object. Got: ' . get_debug_type($decoded) + ); + } + + return $this->ensureObject($decoded); + } + + private function stripCodeFences(string $text): string + { + // Remove ```json ... ``` or ``` ... ``` + if (str_starts_with($text, '```')) { + $text = preg_replace('/^```(?:json)?\s*/', '', $text); + $text = preg_replace('/\s*```$/', '', $text); + } + + return trim($text); + } + + /** + * @param array $array + * @return array + */ + private function ensureObject(array $array): array + { + // Ensure it's an associative array (object), not a list. + if ($array === [] || array_keys($array) !== range(0, count($array) - 1)) { + return $array; + } + + throw new InvalidResponseException( + 'LLM response must be a JSON object, not a JSON array.' + ); + } +} \ No newline at end of file diff --git a/src/Response/Validator.php b/src/Response/Validator.php new file mode 100644 index 0000000..4ee170c --- /dev/null +++ b/src/Response/Validator.php @@ -0,0 +1,169 @@ + $data + * + * @throws InvalidResponseException If validation fails. + */ + public function validate(array $data, JsonFicatorSchema $schema): void + { + $errors = []; + + // Check for unexpected fields. + foreach (array_keys($data) as $key) { + if (!$schema->hasField((string) $key)) { + $errors[] = "Unexpected field '{$key}'."; + } + } + + // Check each schema field. + foreach ($schema->fields as $name => $field) { + $this->validateField($data, $name, $field, $errors); + } + + if ($errors !== []) { + throw new InvalidResponseException( + 'Validation failed: ' . implode(' ', $errors), + $errors, + ); + } + } + + /** + * @param array $data + * @param string[] $errors + */ + private function validateField( + array $data, + string $name, + SchemaField $field, + array &$errors, + ): void { + $present = array_key_exists($name, $data); + $value = $data[$name] ?? null; + + if (!$present) { + if ($field->required) { + $errors[] = "Required field '{$name}' is missing."; + } + return; + } + + if ($value === null) { + if (!$field->nullable) { + $errors[] = "Field '{$name}' is null but not nullable."; + } + return; + } + + $this->validateValue($name, $value, $field, $errors); + } + + /** + * @param string[] $errors + */ + private function validateValue( + string $name, + mixed $value, + SchemaField $field, + array &$errors, + ): void { + switch ($field->type) { + case 'string': + if (!is_string($value)) { + $errors[] = "Field '{$name}' must be a string, got " . get_debug_type($value) . '.'; + } + break; + + case 'int': + if (!is_int($value)) { + $errors[] = "Field '{$name}' must be an integer, got " . get_debug_type($value) . '.'; + } + break; + + case 'float': + if (!is_int($value) && !is_float($value)) { + $errors[] = "Field '{$name}' must be a number, got " . get_debug_type($value) . '.'; + } + break; + + case 'bool': + if (!is_bool($value)) { + $errors[] = "Field '{$name}' must be a boolean, got " . get_debug_type($value) . '.'; + } + break; + + case 'array': + if (!is_array($value)) { + $errors[] = "Field '{$name}' must be an array, got " . get_debug_type($value) . '.'; + break; + } + if ($field->items !== null) { + foreach ($value as $index => $item) { + $this->validateValue("{$name}[{$index}]", $item, $field->items, $errors); + } + } + break; + + case 'object': + if (!is_array($value)) { + $errors[] = "Field '{$name}' must be an object, got " . get_debug_type($value) . '.'; + break; + } + if ($field->properties !== []) { + $this->validateNestedObject($name, $value, $field, $errors); + } + break; + + default: + $errors[] = "Field '{$name}' has unsupported type '{$field->type}'."; + } + } + + /** + * @param array $data + * @param string[] $errors + */ + private function validateNestedObject( + string $name, + array $data, + SchemaField $field, + array &$errors, + ): void { + // Check for unexpected fields in the nested object. + foreach (array_keys($data) as $key) { + if (!isset($field->properties[(string) $key])) { + $errors[] = "Unexpected field '{$name}.{$key}'."; + } + } + + foreach ($field->properties as $childName => $childField) { + $this->validateField($data, $childName, $childField, $errors); + // Prefix errors with the parent path. + } + } +} \ No newline at end of file diff --git a/src/Schema/ArraySchemaResolver.php b/src/Schema/ArraySchemaResolver.php new file mode 100644 index 0000000..cf9a2fa --- /dev/null +++ b/src/Schema/ArraySchemaResolver.php @@ -0,0 +1,170 @@ +`, `array`, `array`, `array` — typed array + * - `array` — array of objects (item schema must be given via a nested + * definition; see {@see self::resolveNested()}) + * - `object` — object with unknown shape + * + * Nested objects are expressed by giving the value as an array of child + * definitions: + * + * ```php + * [ + * 'car' => [ + * 'brand' => 'string', + * 'model' => 'string?', + * ], + * ] + * ``` + * + * The syntax is intentionally small and validated strictly. Unknown types and + * malformed syntax throw {@see SchemaException}. + */ +final class ArraySchemaResolver implements SchemaResolver +{ + /** + * Primitive types that are directly supported. + * + * @var string[] + */ + private const PRIMITIVES = ['string', 'int', 'float', 'bool']; + + /** + * @param string[] $supportedTypes Override the set of supported primitive types. + */ + public function __construct( + private readonly array $supportedTypes = self::PRIMITIVES, + ) { + } + + public function resolve(mixed $definition): JsonFicatorSchema + { + if (!is_array($definition)) { + throw new SchemaException( + 'Array schema must be an array of "field => type" pairs, got: ' . get_debug_type($definition) + ); + } + + $fields = []; + foreach ($definition as $name => $type) { + $name = (string) $name; + if ($name === '') { + throw new SchemaException('Field name cannot be empty.'); + } + $fields[$name] = $this->resolveField($name, $type); + } + + return new JsonFicatorSchema($fields); + } + + /** + * @param mixed $type + */ + private function resolveField(string $name, mixed $type): SchemaField + { + // Nested object expressed as an array of child definitions. + if (is_array($type)) { + $properties = []; + foreach ($type as $childName => $childType) { + $childName = (string) $childName; + if ($childName === '') { + throw new SchemaException("Field '{$name}' has an empty child field name."); + } + $properties[$childName] = $this->resolveField($childName, $childType); + } + + return new SchemaField( + name: $name, + type: 'object', + nullable: false, + required: true, + properties: $properties, + ); + } + + if (!is_string($type)) { + throw new SchemaException( + "Field '{$name}' must be a type string or a nested array, got: " . get_debug_type($type) + ); + } + + return $this->parseTypeString($name, $type); + } + + /** + * Parse a single type string such as "string?", "array", "object". + */ + private function parseTypeString(string $name, string $type): SchemaField + { + $type = trim($type); + if ($type === '') { + throw new SchemaException("Field '{$name}' has an empty type."); + } + + $nullable = str_ends_with($type, '?'); + if ($nullable) { + $type = rtrim(substr($type, 0, -1), ' '); + } + + // array<...> + if (str_starts_with($type, 'array<') && str_ends_with($type, '>')) { + $inner = trim(substr($type, 6, -1)); + $items = $this->parseTypeString($name . '[]', $inner); + + return new SchemaField( + name: $name, + type: 'array', + nullable: $nullable, + required: !$nullable, + items: $items, + ); + } + + if ($type === 'array') { + return new SchemaField( + name: $name, + type: 'array', + nullable: $nullable, + required: !$nullable, + ); + } + + if ($type === 'object') { + return new SchemaField( + name: $name, + type: 'object', + nullable: $nullable, + required: !$nullable, + ); + } + + if (in_array($type, $this->supportedTypes, true)) { + return new SchemaField( + name: $name, + type: $type, + nullable: $nullable, + required: !$nullable, + ); + } + + throw new SchemaException( + "Field '{$name}' has an unsupported type '{$type}'. " + . 'Supported types: ' . implode(', ', $this->supportedTypes) + . ', array, array, object, and nested arrays.' + ); + } +} \ No newline at end of file diff --git a/src/Schema/ClassSchemaResolver.php b/src/Schema/ClassSchemaResolver.php new file mode 100644 index 0000000..3133309 --- /dev/null +++ b/src/Schema/ClassSchemaResolver.php @@ -0,0 +1,255 @@ +, etc. (any array shape) + * - nested models: any class implementing {@see IJsonFicatorModel} + * + * Required/optional semantics (see ARCHITECTURE.md for the full rationale): + * - A non-nullable property is REQUIRED by default. + * - A nullable property is OPTIONAL by default. + * - `#[JsonFicatorRequired]` forces a property to be required. + * - `#[JsonFicatorOptional]` forces a property to be optional. + * + * Only public properties are considered. Static and readonly properties are + * skipped. + */ +final class ClassSchemaResolver implements SchemaResolver +{ + /** + * @param bool $requireInterface Whether the class must implement IJsonFicatorModel. + */ + public function __construct( + private readonly bool $requireInterface = true, + ) { + } + + public function resolve(mixed $definition): JsonFicatorSchema + { + if (!is_string($definition) || !class_exists($definition)) { + throw new SchemaException( + 'Class schema must be a valid class name, got: ' . var_export($definition, true) + ); + } + + $class = $definition; + + if ($this->requireInterface && !is_subclass_of($class, IJsonFicatorModel::class)) { + throw new SchemaException( + "Class '{$class}' must implement " . IJsonFicatorModel::class . '.' + ); + } + + $reflection = new ReflectionClass($class); + $fields = []; + + foreach ($reflection->getProperties() as $property) { + if (!$property->isPublic() || $property->isStatic()) { + continue; + } + + $fields[$property->getName()] = $this->resolveProperty($property); + } + + return new JsonFicatorSchema($fields, $class); + } + + private function resolveProperty(ReflectionProperty $property): SchemaField + { + $name = $property->getName(); + $type = $property->getType(); + + if ($type === null) { + throw new SchemaException( + "Property '{$name}' in class '{$property->getDeclaringClass()->getName()}' has no type. " + . 'Typed properties are required.' + ); + } + + $description = $this->readDescription($property); + $required = $this->resolveRequired($property); + + if ($type instanceof ReflectionUnionType) { + return $this->resolveUnion($name, $type, $required, $description); + } + + if (!($type instanceof ReflectionNamedType)) { + throw new SchemaException( + "Property '{$name}' has an unsupported type: " . (string) $type + ); + } + + return $this->resolveNamed($name, $type, $required, $description); + } + + private function resolveNamed( + string $name, + ReflectionNamedType $type, + bool $required, + ?string $description, + ): SchemaField { + $nullable = $type->allowsNull(); + $typeName = $type->getName(); + + // array / list + if ($typeName === 'array' || $typeName === 'list') { + return new SchemaField( + name: $name, + type: 'array', + nullable: $nullable, + required: $required, + description: $description, + ); + } + + // primitive + if (in_array($typeName, ['string', 'int', 'float', 'bool'], true)) { + return new SchemaField( + name: $name, + type: $typeName, + nullable: $nullable, + required: $required, + description: $description, + ); + } + + // nested model + if (class_exists($typeName)) { + return new SchemaField( + name: $name, + type: 'object', + nullable: $nullable, + required: $required, + description: $description, + properties: $this->resolveNested($typeName), + class: $typeName, + ); + } + + throw new SchemaException( + "Property '{$name}' has an unsupported type '{$typeName}'. " + . 'Supported: string, int, float, bool, array, list, and classes implementing ' + . IJsonFicatorModel::class . '.' + ); + } + + private function resolveUnion( + string $name, + ReflectionUnionType $type, + bool $required, + ?string $description, + ): SchemaField { + $types = $type->getTypes(); + + // Only "null | X" unions are supported. + $nonNull = array_values(array_filter( + $types, + static fn ($t): bool => !($t instanceof ReflectionNamedType && $t->getName() === 'null'), + )); + + if (count($nonNull) !== 1) { + throw new SchemaException( + "Property '{$name}' has an unsupported union type: " . (string) $type + . '. Only "null | " unions are supported.' + ); + } + + $inner = $nonNull[0]; + if (!($inner instanceof ReflectionNamedType)) { + throw new SchemaException( + "Property '{$name}' has an unsupported union type: " . (string) $type + ); + } + + $field = $this->resolveNamed($name, $inner, $required, $description); + + // Re-mark as nullable (the union includes null). + return new SchemaField( + name: $field->name, + type: $field->type, + nullable: true, + required: $field->required, + description: $field->description, + properties: $field->properties, + items: $field->items, + class: $field->class, + ); + } + + /** + * @return array + */ + private function resolveNested(string $class): array + { + $reflection = new ReflectionClass($class); + $properties = []; + + foreach ($reflection->getProperties() as $property) { + if (!$property->isPublic() || $property->isStatic()) { + continue; + } + $properties[$property->getName()] = $this->resolveProperty($property); + } + + return $properties; + } + + private function readDescription(ReflectionProperty $property): ?string + { + $attributes = $property->getAttributes(JsonFicatorDescription::class); + if (count($attributes) === 0) { + return null; + } + + /** @var JsonFicatorDescription $instance */ + $instance = $attributes[0]->newInstance(); + + return $instance->description; + } + + private function resolveRequired(ReflectionProperty $property): bool + { + $hasRequired = $property->getAttributes(JsonFicatorRequired::class) !== []; + $hasOptional = $property->getAttributes(JsonFicatorOptional::class) !== []; + + if ($hasRequired && $hasOptional) { + throw new SchemaException( + "Property '{$property->getName()}' cannot be both required and optional." + ); + } + + if ($hasRequired) { + return true; + } + + if ($hasOptional) { + return false; + } + + // Default: non-nullable => required, nullable => optional. + $type = $property->getType(); + if ($type === null) { + return true; + } + + return !$type->allowsNull(); + } +} \ No newline at end of file diff --git a/src/Schema/JsonFicatorSchema.php b/src/Schema/JsonFicatorSchema.php new file mode 100644 index 0000000..fadb0c0 --- /dev/null +++ b/src/Schema/JsonFicatorSchema.php @@ -0,0 +1,57 @@ + SchemaField. + * @param string|null $modelClass FQCN of the model this schema was resolved from, if any. + */ + public function __construct( + public readonly array $fields, + public readonly ?string $modelClass = null, + ) { + } + + /** + * @return string[] + */ + public function fieldNames(): array + { + return array_keys($this->fields); + } + + public function hasField(string $name): bool + { + return isset($this->fields[$name]); + } + + public function getField(string $name): ?SchemaField + { + return $this->fields[$name] ?? null; + } + + /** + * @return SchemaField[] + */ + public function requiredFields(): array + { + return array_values(array_filter( + $this->fields, + static fn (SchemaField $field): bool => $field->required, + )); + } +} \ No newline at end of file diff --git a/src/Schema/JsonSchemaConverter.php b/src/Schema/JsonSchemaConverter.php new file mode 100644 index 0000000..a078857 --- /dev/null +++ b/src/Schema/JsonSchemaConverter.php @@ -0,0 +1,133 @@ + + */ + public function convert(JsonFicatorSchema $schema): array + { + $properties = []; + $required = []; + + foreach ($schema->fields as $name => $field) { + $properties[$name] = $this->convertField($field); + + if ($this->strict && $field->required) { + $required[] = $name; + } + } + + $result = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if ($this->strict) { + $result['additionalProperties'] = false; + $result['required'] = $required; + } + + return $result; + } + + /** + * @return array + */ + private function convertField(SchemaField $field): array + { + $base = $this->convertBase($field); + + if ($field->nullable) { + // OpenAI strict mode requires anyOf with null for nullable fields. + return [ + 'anyOf' => [$base, ['type' => 'null']], + ]; + } + + return $base; + } + + /** + * @return array + */ + private function convertBase(SchemaField $field): array + { + $result = []; + + switch ($field->type) { + case 'string': + $result['type'] = 'string'; + break; + case 'int': + $result['type'] = 'integer'; + break; + case 'float': + $result['type'] = 'number'; + break; + case 'bool': + $result['type'] = 'boolean'; + break; + case 'array': + $result['type'] = 'array'; + if ($field->items !== null) { + $result['items'] = $this->convertBase($field->items); + } + break; + case 'object': + $result['type'] = 'object'; + if ($field->properties !== []) { + $props = []; + $req = []; + foreach ($field->properties as $childName => $child) { + $props[$childName] = $this->convertField($child); + if ($this->strict && $child->required) { + $req[] = $childName; + } + } + $result['properties'] = $props; + if ($this->strict) { + $result['additionalProperties'] = false; + $result['required'] = $req; + } + } + break; + default: + throw new SchemaException("Unsupported field type '{$field->type}'."); + } + + if ($field->description !== null) { + $result['description'] = $field->description; + } + + return $result; + } +} \ No newline at end of file diff --git a/src/Schema/SchemaField.php b/src/Schema/SchemaField.php new file mode 100644 index 0000000..1f735a9 --- /dev/null +++ b/src/Schema/SchemaField.php @@ -0,0 +1,50 @@ +type, ['string', 'int', 'float', 'bool'], true); + } + + /** + * Whether the field is a structured value (object or array). + */ + public function isStructured(): bool + { + return $this->type === 'object' || $this->type === 'array'; + } +} \ No newline at end of file diff --git a/src/Schema/SchemaResolver.php b/src/Schema/SchemaResolver.php new file mode 100644 index 0000000..ad302dc --- /dev/null +++ b/src/Schema/SchemaResolver.php @@ -0,0 +1,20 @@ + $response + */ + public function __construct( + private readonly array $response, + ) { + } + + public function generate( + string $input, + JsonFicatorSchema $schema, + ?JsonFicatorOptions $options = null, + ): array { + return $this->response; + } +} diff --git a/tests/Fixtures/NestedRequest.php b/tests/Fixtures/NestedRequest.php new file mode 100644 index 0000000..7167473 --- /dev/null +++ b/tests/Fixtures/NestedRequest.php @@ -0,0 +1,13 @@ +markTestSkipped('JSONFICATOR_OPENAI_API_KEY environment variable is not set.'); + } + } + + public function testToJsonExtractsData(): void + { + $provider = new OpenAIProvider([ + 'key' => self::$apiKey, + 'model' => 'gpt-4o-mini', + ]); + + $jsonFicator = new JsonFicator($provider); + + $result = $jsonFicator->toJson('Колодки на ниссан лиф +799999999', [ + 'carBrand' => 'string?', + 'carModel' => 'string?', + 'phoneNumber' => 'string?', + ]); + + $data = $result->toArray(); + + $this->assertArrayHasKey('carBrand', $data); + $this->assertArrayHasKey('carModel', $data); + $this->assertArrayHasKey('phoneNumber', $data); + + // Brand should be detected (Nissan). + $this->assertNotNull($data['carBrand']); + $this->assertIsString($data['carBrand']); + } + + public function testToJsonDoesNotInventValues(): void + { + $provider = new OpenAIProvider([ + 'key' => self::$apiKey, + 'model' => 'gpt-4o-mini', + ]); + + $jsonFicator = new JsonFicator($provider); + + $result = $jsonFicator->toJson('Колодки на Nissan Leaf', [ + 'brand' => 'string', + 'model' => 'string', + 'year' => 'int?', + 'phone' => 'string?', + ]); + + $data = $result->toArray(); + + $this->assertSame('Nissan', $data['brand']); + $this->assertSame('Leaf', $data['model']); + + // Year and phone are not present in the text and are nullable. + $this->assertNull($data['year']); + $this->assertNull($data['phone']); + } +} diff --git a/tests/Unit/ArraySchemaResolverTest.php b/tests/Unit/ArraySchemaResolverTest.php new file mode 100644 index 0000000..5dd95c2 --- /dev/null +++ b/tests/Unit/ArraySchemaResolverTest.php @@ -0,0 +1,134 @@ +resolver = new ArraySchemaResolver(); + } + + public function testResolvesPrimitives(): void + { + $schema = $this->resolver->resolve([ + 'name' => 'string', + 'age' => 'int', + 'price' => 'float', + 'active' => 'bool', + ]); + + $this->assertTrue($schema->hasField('name')); + $this->assertSame('string', $schema->getField('name')?->type); + $this->assertFalse($schema->getField('name')?->nullable); + $this->assertTrue($schema->getField('name')?->required); + + $this->assertSame('int', $schema->getField('age')?->type); + $this->assertSame('float', $schema->getField('price')?->type); + $this->assertSame('bool', $schema->getField('active')?->type); + } + + public function testResolvesNullablePrimitives(): void + { + $schema = $this->resolver->resolve([ + 'name' => 'string?', + 'age' => 'int?', + 'price' => 'float?', + 'active' => 'bool?', + ]); + + $this->assertTrue($schema->getField('name')?->nullable); + $this->assertFalse($schema->getField('name')?->required); + } + + public function testResolvesNestedObject(): void + { + $schema = $this->resolver->resolve([ + 'car' => [ + 'brand' => 'string', + 'model' => 'string?', + ], + ]); + + $car = $schema->getField('car'); + $this->assertNotNull($car); + $this->assertSame('object', $car->type); + $this->assertSame('string', $car->properties['brand']?->type); + $this->assertTrue($car->properties['brand']?->required); + $this->assertTrue($car->properties['model']?->nullable); + } + + public function testResolvesTypedArray(): void + { + $schema = $this->resolver->resolve([ + 'tags' => 'array', + ]); + + $field = $schema->getField('tags'); + $this->assertNotNull($field); + $this->assertSame('array', $field->type); + $this->assertNotNull($field->items); + $this->assertSame('string', $field->items->type); + } + + public function testResolvesGenericArray(): void + { + $schema = $this->resolver->resolve([ + 'items' => 'array', + ]); + + $this->assertSame('array', $schema->getField('items')?->type); + $this->assertNull($schema->getField('items')?->items); + } + + public function testThrowsOnUnknownType(): void + { + $this->expectException(SchemaException::class); + $this->resolver->resolve(['foo' => 'unknown']); + } + + public function testThrowsOnEmptyFieldName(): void + { + $this->expectException(SchemaException::class); + $this->resolver->resolve(['' => 'string']); + } + + public function testThrowsOnEmptyType(): void + { + $this->expectException(SchemaException::class); + $this->resolver->resolve(['foo' => '']); + } + + public function testThrowsOnNonArrayDefinition(): void + { + $this->expectException(SchemaException::class); + $this->resolver->resolve('not-an-array'); + } + + public function testDeepNesting(): void + { + $schema = $this->resolver->resolve([ + 'level1' => [ + 'level2' => [ + 'value' => 'int', + ], + ], + ]); + + $l1 = $schema->getField('level1'); + $this->assertNotNull($l1); + $l2 = $l1->properties['level2'] ?? null; + $this->assertNotNull($l2); + $this->assertSame('object', $l2->type); + $this->assertSame('int', $l2->properties['value']?->type); + } +} diff --git a/tests/Unit/ClassSchemaResolverTest.php b/tests/Unit/ClassSchemaResolverTest.php new file mode 100644 index 0000000..9c40950 --- /dev/null +++ b/tests/Unit/ClassSchemaResolverTest.php @@ -0,0 +1,88 @@ +resolver = new ClassSchemaResolver(); + } + + public function testResolvesSimpleModel(): void + { + $schema = $this->resolver->resolve(VinRequest::class); + + $this->assertSame(VinRequest::class, $schema->modelClass); + $this->assertTrue($schema->hasField('model')); + $this->assertSame('string', $schema->getField('model')?->type); + $this->assertTrue($schema->getField('model')?->nullable); + $this->assertFalse($schema->getField('model')?->required); + } + + public function testResolvesNestedModel(): void + { + $schema = $this->resolver->resolve(NestedRequest::class); + + $car = $schema->getField('car'); + $this->assertNotNull($car); + $this->assertSame('object', $car->type); + $this->assertSame(Car::class, $car->class); + $this->assertTrue($car->required); + + $this->assertSame('string', $car->properties['brand']?->type); + $this->assertTrue($car->properties['brand']?->required); + $this->assertFalse($car->properties['brand']?->nullable); + } + + public function testResolvesAttributes(): void + { + $schema = $this->resolver->resolve(DescribedModel::class); + + $brand = $schema->getField('brand'); + $this->assertNotNull($brand); + $this->assertSame('Марка автомобиля', $brand->description); + + $required = $schema->getField('requiredNullable'); + $this->assertNotNull($required); + $this->assertTrue($required->nullable); + $this->assertTrue($required->required); + + $optional = $schema->getField('optionalNonNullable'); + $this->assertNotNull($optional); + $this->assertFalse($optional->nullable); + $this->assertFalse($optional->required); + } + + public function testThrowsForInvalidClass(): void + { + $this->expectException(SchemaException::class); + $this->resolver->resolve('NonExistentClass'); + } + + public function testRespectsRequireInterfaceFlag(): void + { + // ClassSchemaResolver with requireInterface=false should accept any class + $resolver = new ClassSchemaResolver(requireInterface: false); + + $stdClass = new class { + public string $name; + }; + + $schema = $resolver->resolve($stdClass::class); + $this->assertTrue($schema->hasField('name')); + $this->assertSame('string', $schema->getField('name')?->type); + } +} diff --git a/tests/Unit/ExceptionTest.php b/tests/Unit/ExceptionTest.php new file mode 100644 index 0000000..04ebd85 --- /dev/null +++ b/tests/Unit/ExceptionTest.php @@ -0,0 +1,52 @@ +assertTrue(is_subclass_of(SchemaException::class, JsonFicatorException::class)); + $this->assertTrue(is_subclass_of(ProviderException::class, JsonFicatorException::class)); + $this->assertTrue(is_subclass_of(InvalidResponseException::class, JsonFicatorException::class)); + $this->assertTrue(is_subclass_of(ModelHydrationException::class, JsonFicatorException::class)); + } + + public function testSchemaExceptionCarriesMessage(): void + { + $e = new SchemaException('Invalid type'); + $this->assertSame('Invalid type', $e->getMessage()); + } + + public function testProviderExceptionCarriesPrevious(): void + { + $previous = new \RuntimeException('Network error'); + $e = new ProviderException('API failed', previous: $previous); + + $this->assertSame('API failed', $e->getMessage()); + $this->assertSame($previous, $e->getPrevious()); + } + + public function testInvalidResponseExceptionCarriesErrors(): void + { + $errors = ['Field x is missing']; + $e = new InvalidResponseException('Validation failed', $errors); + + $this->assertSame('Validation failed', $e->getMessage()); + } + + public function testModelHydrationExceptionCarriesMessage(): void + { + $e = new ModelHydrationException('Property foo not found'); + $this->assertSame('Property foo not found', $e->getMessage()); + } +} diff --git a/tests/Unit/HydrationTest.php b/tests/Unit/HydrationTest.php new file mode 100644 index 0000000..5721eee --- /dev/null +++ b/tests/Unit/HydrationTest.php @@ -0,0 +1,102 @@ +hydrator = new ModelHydrator(); + } + + public function testHydratesSimpleModel(): void + { + $data = [ + 'brand' => 'Nissan', + 'model' => 'Leaf', + 'phone' => '+799999999', + ]; + + /** @var VinRequest $result */ + $result = $this->hydrator->hydrate($data, VinRequest::class); + + $this->assertInstanceOf(VinRequest::class, $result); + $this->assertSame('Nissan', $result->brand); + $this->assertSame('Leaf', $result->model); + $this->assertSame('+799999999', $result->phone); + } + + public function testHydratesNullableAsNull(): void + { + $data = [ + 'brand' => 'Nissan', + 'model' => null, + 'phone' => null, + ]; + + /** @var VinRequest $result */ + $result = $this->hydrator->hydrate($data, VinRequest::class); + + $this->assertNull($result->model); + $this->assertNull($result->phone); + } + + public function testHydratesNestedModel(): void + { + $data = [ + 'car' => [ + 'brand' => 'Nissan', + 'model' => 'Leaf', + ], + 'phone' => null, + ]; + + /** @var NestedRequest $result */ + $result = $this->hydrator->hydrate($data, NestedRequest::class); + + $this->assertInstanceOf(NestedRequest::class, $result); + $this->assertInstanceOf(Car::class, $result->car); + $this->assertSame('Nissan', $result->car->brand); + $this->assertSame('Leaf', $result->car->model); + $this->assertNull($result->phone); + } + + public function testThrowsOnMissingProperty(): void + { + $this->expectException(ModelHydrationException::class); + + $this->hydrator->hydrate(['unknown' => 'value'], VinRequest::class); + } + + public function testThrowsOnTypeMismatch(): void + { + $this->expectException(ModelHydrationException::class); + + $this->hydrator->hydrate(['brand' => ['invalid']], VinRequest::class); + } + + public function testCastsIntToStringForNullableString(): void + { + $data = [ + 'brand' => 'Nissan', + 'model' => 2023, + 'phone' => null, + ]; + + /** @var VinRequest $result */ + $result = $this->hydrator->hydrate($data, VinRequest::class); + + $this->assertSame('2023', $result->model); + } +} diff --git a/tests/Unit/JsonFicatorTest.php b/tests/Unit/JsonFicatorTest.php new file mode 100644 index 0000000..4d49ea0 --- /dev/null +++ b/tests/Unit/JsonFicatorTest.php @@ -0,0 +1,113 @@ + 'Nissan', + 'carModel' => 'Leaf', + 'phoneNumber' => '+799999999', + ]); + + $jsonFicator = new JsonFicator($provider); + + $result = $jsonFicator->toJson('Колодки на ниссан лиф +799999999', [ + 'carBrand' => 'string?', + 'carModel' => 'string?', + 'phoneNumber' => 'string', + ]); + + $this->assertSame('Nissan', $result->get('carBrand')); + $this->assertSame('Leaf', $result->get('carModel')); + $this->assertSame('+799999999', $result->get('phoneNumber')); + $this->assertSame( + '{"carBrand":"Nissan","carModel":"Leaf","phoneNumber":"+799999999"}', + $result->toJson() + ); + } + + public function testToModelReturnsHydratedInstance(): void + { + $provider = new FakeProvider([ + 'model' => 'Leaf', + 'brand' => 'Nissan', + 'phone' => '+799999999', + ]); + + $jsonFicator = new JsonFicator($provider); + + /** @var VinRequest $result */ + $result = $jsonFicator->toModel('Колодки на ниссан лиф +799999999', VinRequest::class); + + $this->assertInstanceOf(VinRequest::class, $result); + $this->assertSame('Nissan', $result->brand); + $this->assertSame('Leaf', $result->model); + $this->assertSame('+799999999', $result->phone); + } + + public function testToModelWithNestedClass(): void + { + $provider = new FakeProvider([ + 'car' => [ + 'brand' => 'Nissan', + 'model' => 'Leaf', + ], + 'phone' => null, + ]); + + $jsonFicator = new JsonFicator($provider); + + /** @var NestedRequest $result */ + $result = $jsonFicator->toModel('Колодки на ниссан лиф', NestedRequest::class); + + $this->assertInstanceOf(NestedRequest::class, $result); + $this->assertInstanceOf(Car::class, $result->car); + $this->assertSame('Nissan', $result->car->brand); + $this->assertSame('Leaf', $result->car->model); + $this->assertNull($result->phone); + } + + public function testToJsonWithNullable(): void + { + $provider = new FakeProvider([ + 'brand' => 'Nissan', + 'model' => 'Leaf', + 'year' => null, + 'phone' => null, + ]); + + $jsonFicator = new JsonFicator($provider); + + $result = $jsonFicator->toJson('Колодки на Nissan Leaf', [ + 'brand' => 'string', + 'model' => 'string', + 'year' => 'int?', + 'phone' => 'string?', + ]); + + $this->assertNull($result->get('year')); + $this->assertNull($result->get('phone')); + } + + public function testResultToArray(): void + { + $provider = new FakeProvider(['key' => 'value']); + $jsonFicator = new JsonFicator($provider); + + $result = $jsonFicator->toJson('input', ['key' => 'string']); + + $this->assertSame(['key' => 'value'], $result->toArray()); + } +} diff --git a/tests/Unit/JsonSchemaConverterTest.php b/tests/Unit/JsonSchemaConverterTest.php new file mode 100644 index 0000000..5319d2c --- /dev/null +++ b/tests/Unit/JsonSchemaConverterTest.php @@ -0,0 +1,134 @@ +converter = new JsonSchemaConverter(); + $this->arrayResolver = new ArraySchemaResolver(); + $this->classResolver = new ClassSchemaResolver(); + } + + public function testConvertsPrimitives(): void + { + $schema = $this->arrayResolver->resolve([ + 'name' => 'string', + 'age' => 'int', + 'price' => 'float', + 'active' => 'bool', + ]); + + $jsonSchema = $this->converter->convert($schema); + + $this->assertSame('object', $jsonSchema['type']); + $this->assertSame('string', $jsonSchema['properties']['name']['type']); + $this->assertSame('integer', $jsonSchema['properties']['age']['type']); + $this->assertSame('number', $jsonSchema['properties']['price']['type']); + $this->assertSame('boolean', $jsonSchema['properties']['active']['type']); + } + + public function testConvertsNullable(): void + { + $schema = $this->arrayResolver->resolve([ + 'name' => 'string?', + ]); + + $jsonSchema = $this->converter->convert($schema); + + $this->assertArrayHasKey('anyOf', $jsonSchema['properties']['name']); + $this->assertCount(2, $jsonSchema['properties']['name']['anyOf']); + $this->assertSame('null', $jsonSchema['properties']['name']['anyOf'][1]['type']); + } + + public function testRequiredList(): void + { + $schema = $this->arrayResolver->resolve([ + 'name' => 'string', + 'age' => 'int?', + ]); + + $jsonSchema = $this->converter->convert($schema); + + $this->assertSame(['name'], $jsonSchema['required']); + } + + public function testAdditionalPropertiesFalse(): void + { + $schema = $this->arrayResolver->resolve([ + 'name' => 'string', + ]); + + $jsonSchema = $this->converter->convert($schema); + + $this->assertFalse($jsonSchema['additionalProperties']); + } + + public function testConvertsNestedObject(): void + { + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + 'model' => 'string?', + ], + ]); + + $jsonSchema = $this->converter->convert($schema); + + $car = $jsonSchema['properties']['car']; + $this->assertSame('object', $car['type']); + $this->assertSame('string', $car['properties']['brand']['type']); + $this->assertArrayHasKey('anyOf', $car['properties']['model']); + $this->assertSame(['brand'], $car['required']); + $this->assertFalse($car['additionalProperties']); + } + + public function testConvertsArray(): void + { + $schema = $this->arrayResolver->resolve([ + 'tags' => 'array', + ]); + + $jsonSchema = $this->converter->convert($schema); + + $this->assertSame('array', $jsonSchema['properties']['tags']['type']); + $this->assertSame('string', $jsonSchema['properties']['tags']['items']['type']); + } + + public function testConvertsClassSchema(): void + { + $schema = $this->classResolver->resolve(NestedRequest::class); + $jsonSchema = $this->converter->convert($schema); + + $this->assertSame('object', $jsonSchema['type']); + $this->assertSame('object', $jsonSchema['properties']['car']['type']); + $this->assertSame('string', $jsonSchema['properties']['car']['properties']['brand']['type']); + $this->assertArrayHasKey('anyOf', $jsonSchema['properties']['phone']); + } + + public function testNonStrictMode(): void + { + $converter = new JsonSchemaConverter(strict: false); + $schema = $this->arrayResolver->resolve([ + 'name' => 'string', + ]); + + $jsonSchema = $converter->convert($schema); + + $this->assertArrayNotHasKey('additionalProperties', $jsonSchema); + $this->assertArrayNotHasKey('required', $jsonSchema); + } +} diff --git a/tests/Unit/NestedSchemaTest.php b/tests/Unit/NestedSchemaTest.php new file mode 100644 index 0000000..d618d4b --- /dev/null +++ b/tests/Unit/NestedSchemaTest.php @@ -0,0 +1,153 @@ +arrayResolver = new ArraySchemaResolver(); + $this->classResolver = new ClassSchemaResolver(); + $this->converter = new JsonSchemaConverter(); + $this->validator = new Validator(); + } + + public function testArrayNestedObjectSchema(): void + { + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + 'model' => 'string?', + ], + 'phone' => 'string?', + ]); + + $this->assertTrue($schema->hasField('car')); + $this->assertSame('object', $schema->getField('car')?->type); + $this->assertTrue($schema->getField('car')?->required); + } + + public function testArrayNestedObjectJsonSchema(): void + { + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + 'model' => 'string?', + ], + ]); + + $jsonSchema = $this->converter->convert($schema); + + $this->assertSame('object', $jsonSchema['properties']['car']['type']); + $this->assertArrayHasKey('properties', $jsonSchema['properties']['car']); + $this->assertSame(['brand'], $jsonSchema['properties']['car']['required']); + $this->assertFalse($jsonSchema['properties']['car']['additionalProperties']); + } + + public function testClassNestedObjectSchema(): void + { + $schema = $this->classResolver->resolve(NestedRequest::class); + + $this->assertTrue($schema->hasField('car')); + $this->assertSame('object', $schema->getField('car')?->type); + $this->assertSame(\JsonFicator\Tests\Fixtures\Car::class, $schema->getField('car')?->class); + } + + public function testDeepArrayNesting(): void + { + $schema = $this->arrayResolver->resolve([ + 'level1' => [ + 'level2' => [ + 'value' => 'int', + ], + ], + ]); + + $level1 = $schema->getField('level1'); + $this->assertNotNull($level1); + $level2 = $level1->properties['level2'] ?? null; + $this->assertNotNull($level2); + $this->assertSame('int', $level2->properties['value']?->type); + } + + public function testNestedValidationSuccess(): void + { + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + 'model' => 'string?', + ], + ]); + + $this->validator->validate([ + 'car' => [ + 'brand' => 'Nissan', + 'model' => 'Leaf', + ], + ], $schema); + + $this->addToAssertionCount(1); + } + + public function testNestedValidationFailsOnMissingRequiredChild(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + ], + ]); + + $this->validator->validate([ + 'car' => [ + 'model' => 'Leaf', + ], + ], $schema); + } + + public function testNestedValidationFailsOnUnexpectedChildField(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + ], + ]); + + $this->validator->validate([ + 'car' => [ + 'brand' => 'Nissan', + 'extra' => 'value', + ], + ], $schema); + } + + public function testTypedArrayOfObjects(): void + { + $schema = $this->arrayResolver->resolve([ + 'cars' => 'array', + ]); + + $cars = $schema->getField('cars'); + $this->assertNotNull($cars); + $this->assertSame('array', $cars->type); + $this->assertSame('object', $cars->items?->type); + } +} diff --git a/tests/Unit/NullableTest.php b/tests/Unit/NullableTest.php new file mode 100644 index 0000000..2dd7f47 --- /dev/null +++ b/tests/Unit/NullableTest.php @@ -0,0 +1,109 @@ +arrayResolver = new ArraySchemaResolver(); + $this->classResolver = new ClassSchemaResolver(); + $this->validator = new Validator(); + } + + public function testNullableArrayFieldAcceptsNull(): void + { + $schema = $this->arrayResolver->resolve([ + 'year' => 'int?', + 'phone' => 'string?', + ]); + + $this->validator->validate([ + 'year' => null, + 'phone' => null, + ], $schema); + + $this->addToAssertionCount(1); + } + + public function testNullableArrayFieldAcceptsValue(): void + { + $schema = $this->arrayResolver->resolve([ + 'year' => 'int?', + ]); + + $this->validator->validate(['year' => 2023], $schema); + $this->addToAssertionCount(1); + } + + public function testNonNullableArrayFieldRejectsNull(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'brand' => 'string', + ]); + + $this->validator->validate(['brand' => null], $schema); + } + + public function testNullableClassFieldAcceptsNull(): void + { + $schema = $this->classResolver->resolve(VinRequest::class); + + $this->validator->validate([ + 'model' => null, + 'brand' => null, + 'phone' => null, + ], $schema); + + $this->addToAssertionCount(1); + } + + public function testNullableClassFieldAcceptsValue(): void + { + $schema = $this->classResolver->resolve(VinRequest::class); + + $this->validator->validate([ + 'model' => 'Leaf', + 'brand' => 'Nissan', + 'phone' => '+799999999', + ], $schema); + + $this->addToAssertionCount(1); + } + + public function testNullableArrayFieldIsOptional(): void + { + $schema = $this->arrayResolver->resolve([ + 'year' => 'int?', + ]); + + $this->validator->validate([], $schema); + $this->addToAssertionCount(1); + } + + public function testNonNullableArrayFieldIsRequired(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'brand' => 'string', + ]); + + $this->validator->validate([], $schema); + } +} diff --git a/tests/Unit/OpenAIProviderTest.php b/tests/Unit/OpenAIProviderTest.php new file mode 100644 index 0000000..9fb9286 --- /dev/null +++ b/tests/Unit/OpenAIProviderTest.php @@ -0,0 +1,51 @@ +expectException(ProviderException::class); + $this->expectExceptionMessage('OpenAI API key is required'); + + new OpenAIProvider(); + } + + public function testThrowsWithEmptyApiKey(): void + { + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('OpenAI API key is required'); + + new OpenAIProvider(['key' => '']); + } + + public function testAcceptsApiKeyFromConfig(): void + { + // We cannot make a real request, but we can at least verify the object is created. + $provider = new OpenAIProvider([ + 'key' => 'sk-test', + 'model' => 'gpt-4o-mini', + ]); + + $this->assertInstanceOf(OpenAIProvider::class, $provider); + } + + public function testAcceptsApiKeyFromEnvironment(): void + { + $_SERVER['OPENAI_API_KEY'] = 'sk-env'; + + try { + $provider = new OpenAIProvider(); + $this->assertInstanceOf(OpenAIProvider::class, $provider); + } finally { + unset($_SERVER['OPENAI_API_KEY']); + } + } +} diff --git a/tests/Unit/ResponseParserTest.php b/tests/Unit/ResponseParserTest.php new file mode 100644 index 0000000..5753bf5 --- /dev/null +++ b/tests/Unit/ResponseParserTest.php @@ -0,0 +1,63 @@ +parser = new ResponseParser(); + } + + public function testParsesArray(): void + { + $data = ['brand' => 'Nissan']; + $this->assertSame($data, $this->parser->parse($data)); + } + + public function testParsesJsonString(): void + { + $json = '{"brand":"Nissan","model":"Leaf"}'; + $expected = ['brand' => 'Nissan', 'model' => 'Leaf']; + + $this->assertSame($expected, $this->parser->parse($json)); + } + + public function testParsesWithCodeFence(): void + { + $json = "```json\n{\"brand\":\"Nissan\"}\n```"; + $this->assertSame(['brand' => 'Nissan'], $this->parser->parse($json)); + } + + public function testParsesWithPlainCodeFence(): void + { + $json = "```\n{\"brand\":\"Nissan\"}\n```"; + $this->assertSame(['brand' => 'Nissan'], $this->parser->parse($json)); + } + + public function testThrowsOnInvalidJson(): void + { + $this->expectException(InvalidResponseException::class); + $this->parser->parse('not json'); + } + + public function testThrowsOnJsonArray(): void + { + $this->expectException(InvalidResponseException::class); + $this->parser->parse('[1, 2, 3]'); + } + + public function testThrowsOnNonStringNonArray(): void + { + $this->expectException(InvalidResponseException::class); + $this->parser->parse(42); + } +} diff --git a/tests/Unit/ValidatorTest.php b/tests/Unit/ValidatorTest.php new file mode 100644 index 0000000..efa8b60 --- /dev/null +++ b/tests/Unit/ValidatorTest.php @@ -0,0 +1,172 @@ +validator = new Validator(); + $this->arrayResolver = new ArraySchemaResolver(); + } + + public function testValidatesPrimitives(): void + { + $schema = $this->arrayResolver->resolve([ + 'brand' => 'string', + 'year' => 'int', + 'price' => 'float', + 'active' => 'bool', + ]); + + $this->validator->validate([ + 'brand' => 'Nissan', + 'year' => 2023, + 'price' => 12.5, + 'active' => true, + ], $schema); + + $this->addToAssertionCount(1); + } + + public function testValidatesNullable(): void + { + $schema = $this->arrayResolver->resolve([ + 'phone' => 'string?', + ]); + + $this->validator->validate(['phone' => null], $schema); + $this->addToAssertionCount(1); + } + + public function testThrowsOnNullForNonNullable(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'brand' => 'string', + ]); + + $this->validator->validate(['brand' => null], $schema); + } + + public function testThrowsOnMissingRequired(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'brand' => 'string', + ]); + + $this->validator->validate([], $schema); + } + + public function testThrowsOnWrongType(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'year' => 'int', + ]); + + $this->validator->validate(['year' => 'not-int'], $schema); + } + + public function testThrowsOnUnexpectedField(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'brand' => 'string', + ]); + + $this->validator->validate(['brand' => 'Nissan', 'extra' => 'value'], $schema); + } + + public function testValidatesNestedObject(): void + { + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + 'model' => 'string?', + ], + ]); + + $this->validator->validate([ + 'car' => [ + 'brand' => 'Nissan', + 'model' => null, + ], + ], $schema); + + $this->addToAssertionCount(1); + } + + public function testThrowsOnUnexpectedNestedField(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'car' => [ + 'brand' => 'string', + ], + ]); + + $this->validator->validate([ + 'car' => [ + 'brand' => 'Nissan', + 'extra' => 'value', + ], + ], $schema); + } + + public function testValidatesArrayItems(): void + { + $schema = $this->arrayResolver->resolve([ + 'tags' => 'array', + ]); + + $this->validator->validate(['tags' => ['a', 'b']], $schema); + $this->addToAssertionCount(1); + } + + public function testThrowsOnArrayItemTypeMismatch(): void + { + $this->expectException(InvalidResponseException::class); + + $schema = $this->arrayResolver->resolve([ + 'tags' => 'array', + ]); + + $this->validator->validate(['tags' => [1, 'not-int']], $schema); + } + + public function testValidatesClassSchema(): void + { + $resolver = new ClassSchemaResolver(); + $schema = $resolver->resolve(NestedRequest::class); + + $this->validator->validate([ + 'car' => [ + 'brand' => 'Nissan', + 'model' => 'Leaf', + ], + 'phone' => null, + ], $schema); + + $this->addToAssertionCount(1); + } +}