diff --git a/README.md b/README.md index 717e358..0efa5f4 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,128 @@ The return type of the interface method selects how the HTTP response is handled | `string` | Raw response body | | PSR-7 `MessageInterface`| The HTTP message object | +### Mapping responses to a domain object + +Instead of a raw array, a method can return typed, immutable domain objects. +Give `#[WebQuery]` a `factory` (and a `type`) — this is the same factory +mechanism `ray/media-query` provides for `#[DbQuery]`, applied to HTTP +responses. + +```php + */ + public function list(string $status): array; +} +``` + +The factory is resolved through the DI injector, so it can depend on domain +services and apply business logic while building the object: + +```php +tax->applyTax($price)); + } +} + +final class Product +{ + public function __construct( + public readonly string $name, + public readonly int $price, + ) { + } +} +``` + +The decoded JSON is passed to the factory method as **named arguments**: each +JSON key is matched to a parameter by name, unknown keys are ignored, and a +missing required argument throws `InvalidWebFactoryKeyException`. (This is the +web counterpart of media-query's positional `PDO::FETCH_FUNC` binding.) + +`type` selects single object vs. list: + +| `type` | JSON response | Result | +|--------------|--------------------------|-----------------| +| `'row'` | object `{...}` | one object | +| `'row_list'` | array `[{...}, {...}]` | `array` | + +`type` defaults to `'row_list'`. A `'row'` method whose response is a list +takes the first element; a `'row_list'` method whose response is a single +object wraps it into a one-element list. + +You can also map straight to an entity **without** a factory: when the return +type (or the `@return array` docblock) is a class, each response is +hydrated through the entity constructor. + +```php +#[WebQuery('product_item', type: 'row')] +public function get(string $id): Product; // built via Product::__construct +``` + +### Composing results with PostFetch + +To wrap or aggregate the fetched objects into another type (totals, metadata, +…), let the return type implement `PostFetchInterface`. Its static +`fromContext()` receives the fetch result and returns the final object. It runs +after the factory, carries no dependencies by design, and is the web analogue +of media-query's `PostQueryInterface` (named *PostFetch* because a web call is a +single fetch, with no multi-statement query context to span). + +```php + $items */ + public function __construct( + public readonly array $items, + public readonly int $total, + ) { + } + + public static function fromContext(PostFetchContext $context): static + { + /** @var array $items */ + $items = is_array($context->result) ? $context->result : []; + + return new self($items, count($items)); + } +} +``` + +```php +#[WebQuery('product_list', factory: ProductFactory::class)] +public function listAggregate(string $status): ProductList; +``` + +`PostFetchContext` exposes the fetch `result`, the original method arguments +(`query`), and the `#[WebQuery]` annotation (`webQuery`). + ## Features - **Web API Queries**: Execute HTTP requests via interface methods - **URI Template Support**: Dynamic URL parameter binding with `{param}` syntax - **Multiple Response Types**: JSON array, string, or PSR-7 message +- **Domain Object Mapping (BDR)**: Map responses to typed domain objects via an injectable factory, with optional `PostFetch` composition - **Parameter Injection**: Automatic parameter conversion and injection - **HTTP Client Integration**: Built on the Guzzle HTTP client diff --git a/composer.json b/composer.json index e4af30a..5a8a1d4 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "guzzlehttp/guzzle": "^7.2", + "phpdocumentor/reflection-docblock": "^5.3 || ^6.0", "psr/http-message": "^2.0", "ray/aop": "^2.18", "ray/di": "^2.18", diff --git a/src/Annotation/WebQuery.php b/src/Annotation/WebQuery.php index 8ba2c6b..739b855 100644 --- a/src/Annotation/WebQuery.php +++ b/src/Annotation/WebQuery.php @@ -11,6 +11,8 @@ final class WebQuery { public function __construct( public string $id, + public string $type = 'row_list', + public string $factory = '', ) { } } diff --git a/src/Exception/EntityWithoutConstructorException.php b/src/Exception/EntityWithoutConstructorException.php new file mode 100644 index 0000000..6f8ceab --- /dev/null +++ b/src/Exception/EntityWithoutConstructorException.php @@ -0,0 +1,20 @@ + $availableKeys Keys present in the response. + */ + public function __construct(string $target, string $missingParam, array $availableKeys) + { + $available = implode(', ', $availableKeys); + parent::__construct( + "'{$target}' requires parameter '{$missingParam}' but it was not found in response keys: [{$available}]", + ); + } +} diff --git a/src/MediaQueryWebModule.php b/src/MediaQueryWebModule.php index 49433c4..3fd24ec 100644 --- a/src/MediaQueryWebModule.php +++ b/src/MediaQueryWebModule.php @@ -7,7 +7,10 @@ use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use Override; +use phpDocumentor\Reflection\DocBlockFactory; +use phpDocumentor\Reflection\DocBlockFactoryInterface; use Ray\Di\AbstractModule; +use Ray\MediaQuery\Annotation\Qualifier\FactoryMethod; use Ray\MediaQuery\Annotation\Qualifier\UriTemplateBindings; use Ray\MediaQuery\Annotation\Qualifier\WebApiList; use Ray\MediaQuery\Annotation\WebQuery; @@ -38,5 +41,11 @@ public function configure(): void $this->bind()->annotatedWith(WebApiList::class)->toInstance($config); $this->bind()->annotatedWith(UriTemplateBindings::class)->toInstance($this->config->urlTemplateBindings); + + // BDR factory layer + $this->bind(DocBlockFactoryInterface::class)->toInstance(DocBlockFactory::createInstance()); + $this->bind(ReturnEntityInterface::class)->to(ReturnEntity::class); + $this->bind(WebResponseMapperInterface::class)->to(WebResponseMapper::class); + $this->bind()->annotatedWith(FactoryMethod::class)->toInstance('factory'); } } diff --git a/src/PostFetchContext.php b/src/PostFetchContext.php new file mode 100644 index 0000000..dbe6200 --- /dev/null +++ b/src/PostFetchContext.php @@ -0,0 +1,28 @@ + $query Method arguments passed to the interceptor. + * @param WebQuery $webQuery The annotation (id, type, factory). + */ + public function __construct( + public readonly mixed $result, + public readonly array $query, + public readonly WebQuery $webQuery, + ) { + } +} diff --git a/src/PostFetchInterface.php b/src/PostFetchInterface.php new file mode 100644 index 0000000..7feb3a4 --- /dev/null +++ b/src/PostFetchInterface.php @@ -0,0 +1,19 @@ +|string|MessageInterface */ #[Override] - public function invoke(MethodInvocation $invocation): array|string|MessageInterface + public function invoke(MethodInvocation $invocation): mixed { $method = $invocation->getMethod(); /** @var WebQuery $webQuery */ @@ -36,8 +40,83 @@ public function invoke(MethodInvocation $invocation): array|string|MessageInterf /** @var array $values */ $values = $this->paramInjector->getArguments($invocation); $request = $this->webApiList[$webQuery->id]; - $returnType = $method->getReturnType(); + $entity = ($this->returnEntity)($method); + + // No object mapping requested: return the raw array / string / PSR-7 message. + if ($webQuery->factory === '' && $entity === null) { + return $this->rawResponse($returnType, $request, $values); + } + + /** @var array $body */ + $body = $this->webApiQuery->request($request['method'], $request['path'], $values); + $postFetch = $this->postFetchClass($returnType); + $isRow = $this->isRow($webQuery, $returnType, $postFetch !== null); + + /** @psalm-suppress MixedAssignment */ + $result = $this->mapper->map($webQuery, $entity, $isRow, $body); + + if ($postFetch !== null) { + return $postFetch::fromContext(new PostFetchContext($result, $values, $webQuery)); + } + + return $result; + } + + /** + * Resolve the return type to a PostFetchInterface class, or null. + * + * @return class-string|null + */ + private function postFetchClass(ReflectionType|null $returnType): string|null + { + if ( + $returnType instanceof ReflectionNamedType + && class_exists($returnType->getName()) + && is_a($returnType->getName(), PostFetchInterface::class, true) + ) { + /** @var class-string */ + return $returnType->getName(); + } + + return null; + } + + /** + * A single object is returned for `type: 'row'`, a nullable union (`T|null`), + * or a named (non-array, non-PostFetch) class return type. Otherwise a list. + */ + private function isRow(WebQuery $webQuery, ReflectionType|null $returnType, bool $isPostFetch): bool + { + if ($webQuery->type === 'row') { + return true; + } + + if ($isPostFetch) { + return false; + } + + if ($returnType instanceof ReflectionUnionType) { + return true; + } + + return $returnType instanceof ReflectionNamedType && $returnType->getName() !== 'array'; + } + + /** + * Return the raw response in the form requested by the return type: + * array / string / MessageInterface. + * + * @param array{method: string, path: string} $request + * @param array $values + * + * @return array|string|MessageInterface + */ + private function rawResponse( + ReflectionType|null $returnType, + array $request, + array $values, + ): array|string|MessageInterface { if ( $returnType instanceof ReflectionNamedType && is_a($returnType->getName(), MessageInterface::class, true) diff --git a/src/WebResponseMapper.php b/src/WebResponseMapper.php new file mode 100644 index 0000000..00c7bf7 --- /dev/null +++ b/src/WebResponseMapper.php @@ -0,0 +1,191 @@ + $body + */ + #[Override] + public function map(WebQuery $webQuery, string|null $entity, bool $isRow, array $body): mixed + { + $convert = $this->converter($webQuery, $entity); + + return $isRow ? $this->row($body, $convert) : $this->list($body, $convert); + } + + /** @param array $body */ + private function row(array $body, Closure $convert): mixed + { + if ($body === []) { + return null; + } + + /** @var array|null $row */ + $row = array_is_list($body) ? ($body[0] ?? null) : $body; + + return $row === null ? null : $convert($row); + } + + /** + * @param array $body + * + * @return array + */ + private function list(array $body, Closure $convert): array + { + if ($body === []) { + return []; + } + + /** @var array> $rows */ + $rows = array_is_list($body) ? $body : [$body]; + + return array_map($convert, $rows); + } + + /** @param class-string|null $entity */ + private function converter(WebQuery $webQuery, string|null $entity): Closure + { + if ($webQuery->factory !== '') { + return $this->factoryConverter($webQuery->factory); + } + + return $this->entityConverter($entity); + } + + private function factoryConverter(string $factory): Closure + { + $method = $this->factoryMethod; + if (! class_exists($factory) || ! method_exists($factory, $method)) { + throw new InvalidWebFactoryException($factory, $method); + } + + $ref = new ReflectionMethod($factory, $method); + if (! $ref->isPublic()) { + throw new InvalidWebFactoryException($factory, $method); + } + + if ($ref->isStatic()) { + return function (array $row) use ($factory, $method, $ref): mixed { + /** @var array $row */ + $args = $this->bind($ref, $row); + + /** @psalm-suppress MixedMethodCall */ + return $factory::$method(...$args); + }; + } + + $instance = $this->injector->getInstance($factory); + + return function (array $row) use ($instance, $method, $ref): mixed { + /** @var array $row */ + $args = $this->bind($ref, $row); + + /** @psalm-suppress MixedMethodCall */ + return $instance->$method(...$args); + }; + } + + /** @param class-string|null $entity */ + private function entityConverter(string|null $entity): Closure + { + if ($entity === null || ! class_exists($entity)) { + throw new InvalidWebEntityException((string) $entity); + } + + $ctor = (new ReflectionClass($entity))->getConstructor(); + if ($ctor === null) { + throw new EntityWithoutConstructorException($entity); + } + + return function (array $row) use ($entity, $ctor): object { + /** @var array $row */ + $args = $this->bind($ctor, $row); + + /** @psalm-suppress MixedMethodCall */ + return new $entity(...$args); + }; + } + + /** + * Match response keys to the target parameters by name. + * + * - Extra keys are discarded. + * - Missing keys with a default value are omitted (the default is used). + * - Missing nullable keys receive null. + * - Missing required keys throw MissingResponseKeyException. + * + * @param array $row + * + * @return array + */ + private function bind(ReflectionMethod $ref, array $row): array + { + $args = []; + foreach ($ref->getParameters() as $param) { + $name = $param->getName(); + if (array_key_exists($name, $row)) { + /** @psalm-suppress MixedAssignment */ + $args[$name] = $row[$name]; + continue; + } + + if ($param->isDefaultValueAvailable()) { + continue; + } + + if ($param->allowsNull()) { + $args[$name] = null; + continue; + } + + $target = $ref->getDeclaringClass()->getName() . '::' . $ref->getName(); + + throw new MissingResponseKeyException($target, $name, array_keys($row)); + } + + return $args; + } +} diff --git a/src/WebResponseMapperInterface.php b/src/WebResponseMapperInterface.php new file mode 100644 index 0000000..131d4f9 --- /dev/null +++ b/src/WebResponseMapperInterface.php @@ -0,0 +1,18 @@ + $body + */ + public function map(WebQuery $webQuery, string|null $entity, bool $isRow, array $body): mixed; +} diff --git a/tests/Fake/FakeInjector.php b/tests/Fake/FakeInjector.php new file mode 100644 index 0000000..599b98c --- /dev/null +++ b/tests/Fake/FakeInjector.php @@ -0,0 +1,43 @@ + */ + private array $instances = []; + + public function bind(object $instance): self + { + $this->instances[$instance::class] = $instance; + + return $this; + } + + /** + * @param ''|class-string $interface + * @param string $name + */ + #[Override] + public function getInstance($interface, $name = Name::ANY): mixed + { + if (isset($this->instances[$interface])) { + return $this->instances[$interface]; + } + + throw new RuntimeException("FakeInjector: not bound: {$interface}"); + } +} diff --git a/tests/Fake/FakeNonPublicProductFactory.php b/tests/Fake/FakeNonPublicProductFactory.php new file mode 100644 index 0000000..ee9c3df --- /dev/null +++ b/tests/Fake/FakeNonPublicProductFactory.php @@ -0,0 +1,13 @@ +tax->applyTax($price)); + } +} diff --git a/tests/Fake/FakeProductList.php b/tests/Fake/FakeProductList.php new file mode 100644 index 0000000..35434b9 --- /dev/null +++ b/tests/Fake/FakeProductList.php @@ -0,0 +1,28 @@ + $items */ + public function __construct( + public readonly array $items, + public readonly int $total, + ) { + } + + #[Override] + public static function fromContext(PostFetchContext $context): static + { + /** @var array $items */ + $items = is_array($context->result) ? $context->result : []; + + return new self($items, count($items)); + } +} diff --git a/tests/Fake/FakeProductModule.php b/tests/Fake/FakeProductModule.php new file mode 100644 index 0000000..d1e7fa8 --- /dev/null +++ b/tests/Fake/FakeProductModule.php @@ -0,0 +1,32 @@ +bind(ClientInterface::class)->toInstance(new FakeWebClient($this->body)); + $this->bind(FakeTaxCalculator::class)->toInstance(new FakeTaxCalculator()); + $this->bind(FakeProductFactory::class)->toInstance( + new FakeProductFactory(new FakeTaxCalculator()), + ); + } +} diff --git a/tests/Fake/FakeStaticProductFactory.php b/tests/Fake/FakeStaticProductFactory.php new file mode 100644 index 0000000..a3ca6a0 --- /dev/null +++ b/tests/Fake/FakeStaticProductFactory.php @@ -0,0 +1,13 @@ + */ + #[WebQuery(id: 'foo_product', type: 'row_list')] + public function list(string $status): array; + + /** Returns an entity via a DI-resolved instance factory. */ + #[WebQuery(id: 'foo_product', type: 'row', factory: FakeProductFactory::class)] + public function getWithTax(string $id): FakeProductEntity; + + /** Returns a list via a DI-resolved instance factory. */ + /** @return array */ + #[WebQuery(id: 'foo_product', type: 'row_list', factory: FakeProductFactory::class)] + public function listWithTax(string $status): array; + + /** Returns an entity via a static factory. */ + #[WebQuery(id: 'foo_product', type: 'row', factory: FakeStaticProductFactory::class)] + public function getStatic(string $id): FakeProductEntity; + + /** A genuine union return type maps to a single object even without type: 'row'. */ + #[WebQuery(id: 'foo_product', factory: FakeProductFactory::class)] + public function getUnion(string $id): FakeProductEntity|FakeProductList; + + /** Returns a PostFetch aggregate object. */ + #[WebQuery(id: 'foo_product', type: 'row_list', factory: FakeProductFactory::class)] + public function getList(string $status): FakeProductList; +} diff --git a/tests/Fake/web_product.json b/tests/Fake/web_product.json new file mode 100644 index 0000000..0adf64c --- /dev/null +++ b/tests/Fake/web_product.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../docs/schema/web_query.json", + "webQuery": [ + {"id": "foo_product", "method": "GET", "path": "https://{domain}/products/{id}"} + ] +} diff --git a/tests/PostFetchContextTest.php b/tests/PostFetchContextTest.php new file mode 100644 index 0000000..762ee9c --- /dev/null +++ b/tests/PostFetchContextTest.php @@ -0,0 +1,37 @@ + 'active'], $webQuery); + + $list = FakeProductList::fromContext($ctx); + + $this->assertInstanceOf(FakeProductList::class, $list); + $this->assertCount(2, $list->items); + $this->assertSame(2, $list->total); + $this->assertSame('Widget', $list->items[0]->name); + } + + public function testContextHoldsWebQueryAnnotation(): void + { + $webQuery = new WebQuery(id: 'test_id', type: 'row', factory: 'MyFactory'); + $ctx = new PostFetchContext([], ['key' => 'val'], $webQuery); + $this->assertSame('test_id', $ctx->webQuery->id); + $this->assertSame('row', $ctx->webQuery->type); + $this->assertSame('MyFactory', $ctx->webQuery->factory); + } +} diff --git a/tests/WebQueryBdrTest.php b/tests/WebQueryBdrTest.php new file mode 100644 index 0000000..f5b2242 --- /dev/null +++ b/tests/WebQueryBdrTest.php @@ -0,0 +1,114 @@ + 'api.example.com'])); + $baseModule = new MediaQueryBaseModule($mediaQueries); + $baseModule->install($webModule); + $baseModule->override(new FakeProductModule($body)); + + return (new Injector($baseModule))->getInstance(FooProductInterface::class); + } + + public function testEntityWithConstructor(): void + { + $body = '{"name":"Widget","price":100}'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->get('1'); + $this->assertInstanceOf(FakeProductEntity::class, $result); + $this->assertSame('Widget', $result->name); + $this->assertSame(100, $result->price); + } + + public function testInjectedFactoryAppliesTax(): void + { + $body = '{"name":"Widget","price":100}'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->getWithTax('1'); + $this->assertInstanceOf(FakeProductEntity::class, $result); + // FakeTaxCalculator * 1.1 = 110 + $this->assertSame(110, $result->price); + } + + public function testStaticFactoryBuildsEntity(): void + { + $body = '{"name":"Widget","price":100}'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->getStatic('1'); + $this->assertInstanceOf(FakeProductEntity::class, $result); + $this->assertSame('Widget', $result->name); + $this->assertSame(100, $result->price); + } + + public function testUnionReturnTypeMapsToSingleObject(): void + { + $body = '{"name":"Widget","price":100}'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->getUnion('1'); + $this->assertInstanceOf(FakeProductEntity::class, $result); + // FakeProductFactory applies tax: 100 -> 110 + $this->assertSame(110, $result->price); + } + + public function testRowListWithFactory(): void + { + $body = '[{"name":"Widget","price":100},{"name":"Gadget","price":200}]'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->listWithTax('active'); + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertInstanceOf(FakeProductEntity::class, $result[0]); + $this->assertSame(110, $result[0]->price); + $this->assertSame(220, $result[1]->price); + } + + public function testRowListWithoutFactory(): void + { + $body = '[{"name":"Widget","price":100},{"name":"Gadget","price":200}]'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->list('active'); + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertInstanceOf(FakeProductEntity::class, $result[0]); + } + + public function testPostFetchWrapsResultInAggregate(): void + { + $body = '[{"name":"Widget","price":100},{"name":"Gadget","price":200}]'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->getList('active'); + $this->assertInstanceOf(FakeProductList::class, $result); + $this->assertSame(2, $result->total); + $this->assertCount(2, $result->items); + } + + public function testLegacyArrayPathUnchanged(): void + { + // FooItemInterface::item() still hits legacy path (array return, no factory, no entity) + $mediaQueries = Queries::fromClasses([WebApi\FooItemInterface::class]); + $mediaQueryJson = __DIR__ . '/Fake/web_query.json'; + $webModule = new MediaQueryWebModule(new WebQueryConfig($mediaQueryJson, ['domain' => 'ray-di.github.io'])); + $baseModule = new MediaQueryBaseModule($mediaQueries); + $baseModule->install($webModule); + $baseModule->override(new FakeWebClientModule()); + $injector = new Injector($baseModule); + $fooItem = $injector->getInstance(WebApi\FooItemInterface::class); + $result = $fooItem->item('web_query'); + $this->assertSame('Web query schema', $result['title']); + } +} diff --git a/tests/WebResponseMapperTest.php b/tests/WebResponseMapperTest.php new file mode 100644 index 0000000..d32fdfa --- /dev/null +++ b/tests/WebResponseMapperTest.php @@ -0,0 +1,155 @@ + 'Widget', 'price' => 100]; + $result = $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, true, $row); + $this->assertInstanceOf(FakeProductEntity::class, $result); + /** @var FakeProductEntity $result */ + $this->assertSame('Widget', $result->name); + $this->assertSame(100, $result->price); + } + + public function testEntityRowList(): void + { + $body = [['name' => 'Widget', 'price' => 100], ['name' => 'Gadget', 'price' => 200]]; + $result = $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, false, $body); + /** @var array $result */ + $this->assertCount(2, $result); + $this->assertInstanceOf(FakeProductEntity::class, $result[0]); + $this->assertSame('Gadget', $result[1]->name); + } + + public function testInjectedFactoryAppliesBusinessLogic(): void + { + $injector = (new FakeInjector())->bind(new FakeProductFactory(new FakeTaxCalculator())); + $webQuery = new WebQuery(id: 'id', factory: FakeProductFactory::class); + $result = $this->mapper($injector)->map($webQuery, null, true, ['name' => 'Widget', 'price' => 100]); + $this->assertInstanceOf(FakeProductEntity::class, $result); + // FakeTaxCalculator applies *1.1: 100 -> 110 + /** @var FakeProductEntity $result */ + $this->assertSame(110, $result->price); + } + + public function testStaticFactory(): void + { + $webQuery = new WebQuery(id: 'id', factory: FakeStaticProductFactory::class); + $result = $this->mapper()->map($webQuery, null, true, ['name' => 'Widget', 'price' => 100]); + $this->assertInstanceOf(FakeProductEntity::class, $result); + /** @var FakeProductEntity $result */ + $this->assertSame(100, $result->price); + } + + public function testExtraKeysAreIgnored(): void + { + $row = ['name' => 'Widget', 'price' => 100, 'extra' => 'x']; + $result = $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, true, $row); + $this->assertInstanceOf(FakeProductEntity::class, $result); + /** @var FakeProductEntity $result */ + $this->assertSame('Widget', $result->name); + } + + public function testMissingRequiredKeyThrows(): void + { + $this->expectException(MissingResponseKeyException::class); + $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, true, ['name' => 'Widget']); + } + + public function testMissingKeyFallsBackToDefaultOrNull(): void + { + // 'note' is nullable without a default -> null is injected. + // 'price' has a default -> the argument is omitted and the default applies. + $entity = new class ('', null) { + public function __construct( + public readonly string $name, + public readonly string|null $note, + public readonly int $price = 42, + ) { + } + }; + $result = $this->mapper()->map(new WebQuery('id'), $entity::class, true, ['name' => 'Widget']); + /** @var object{name: string, note: string|null, price: int} $result */ + $this->assertSame('Widget', $result->name); + $this->assertNull($result->note); + $this->assertSame(42, $result->price); + } + + public function testEntityWithoutConstructorThrows(): void + { + $noCtor = new class { + public string $name = ''; + }; + $this->expectException(EntityWithoutConstructorException::class); + $this->mapper()->map(new WebQuery('id'), $noCtor::class, true, ['name' => 'x']); + } + + public function testInvalidFactoryThrows(): void + { + /** @var class-string $missing */ + $missing = 'NotAClass\\Factory'; + $webQuery = new WebQuery(id: 'id', factory: $missing); + $this->expectException(InvalidWebFactoryException::class); + $this->mapper()->map($webQuery, null, true, ['name' => 'Widget']); + } + + public function testNonPublicFactoryMethodThrows(): void + { + $webQuery = new WebQuery(id: 'id', factory: FakeNonPublicProductFactory::class); + $this->expectException(InvalidWebFactoryException::class); + $this->mapper()->map($webQuery, null, true, ['name' => 'Widget', 'price' => 100]); + } + + public function testEntityClassNotFoundThrows(): void + { + /** @var class-string $missing */ + $missing = 'NotAnEntity\\Klass'; + $this->expectException(InvalidWebEntityException::class); + $this->mapper()->map(new WebQuery('id'), $missing, true, ['name' => 'x']); + } + + public function testEmptyBodyRowReturnsNull(): void + { + $this->assertNull($this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, true, [])); + } + + public function testEmptyBodyRowListReturnsEmptyArray(): void + { + $this->assertSame([], $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, false, [])); + } + + public function testRowFromListTakesFirstElement(): void + { + $body = [['name' => 'Widget', 'price' => 100], ['name' => 'Gadget', 'price' => 200]]; + $result = $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, true, $body); + $this->assertInstanceOf(FakeProductEntity::class, $result); + /** @var FakeProductEntity $result */ + $this->assertSame('Widget', $result->name); + } + + public function testRowListWrapsSingleObject(): void + { + $row = ['name' => 'Widget', 'price' => 100]; + $result = $this->mapper()->map(new WebQuery('id'), FakeProductEntity::class, false, $row); + /** @var array $result */ + $this->assertCount(1, $result); + $this->assertSame('Widget', $result[0]->name); + } +}