From 05a60b7f39d4fe6d0087456c72f41d54991f33dd Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 02:07:39 +0900 Subject: [PATCH 01/10] Add BDR factory and PostFetch layers to WebQuery Add `factory` and `type` parameters to #[WebQuery], mirroring #[DbQuery], so an HTTP response can be mapped to domain objects through a DI-resolved factory (the BDR factory layer) or a return-type entity. - WebFetch* classes hydrate JSON (associative arrays) into objects via named-argument spread, the web counterpart of media-query's PDO-based Fetch* classes, which cannot be reused as they are PDO-coupled. - PostFetchInterface / PostFetchContext let a return type compose the factory output through a static named constructor (fromContext). - Reuse ReturnEntity and the FactoryMethod qualifier from ray/media-query. - The legacy array / string / MessageInterface return paths are preserved. --- src/Annotation/WebQuery.php | 2 + src/Exception/InvalidWebEntityException.php | 28 +++++ .../InvalidWebFactoryKeyException.php | 31 +++++ src/MediaQueryWebModule.php | 6 + src/PostFetchContext.php | 28 +++++ src/PostFetchInterface.php | 19 +++ src/WebFetchAssoc.php | 37 ++++++ src/WebFetchClass.php | 54 ++++++++ src/WebFetchFactory.php | 72 +++++++++++ src/WebFetchFactoryInterface.php | 19 +++ src/WebFetchInjectionFactory.php | 109 ++++++++++++++++ src/WebFetchInterface.php | 25 ++++ src/WebFetchNewInstance.php | 88 +++++++++++++ src/WebFetchStaticFactory.php | 90 +++++++++++++ src/WebQueryInterceptor.php | 118 +++++++++++++++++- tests/Fake/FakeInjector.php | 43 +++++++ tests/Fake/FakeNoCtorEntity.php | 14 +++ tests/Fake/FakeProductEntity.php | 14 +++ tests/Fake/FakeProductFactory.php | 24 ++++ tests/Fake/FakeProductList.php | 28 +++++ tests/Fake/FakeProductModule.php | 32 +++++ tests/Fake/FakeStaticProductFactory.php | 13 ++ tests/Fake/FakeTaxCalculator.php | 13 ++ tests/Fake/WebApi/FooProductInterface.php | 45 +++++++ tests/Fake/web_product.json | 6 + tests/PostFetchContextTest.php | 37 ++++++ tests/WebFetchClassTest.php | 34 +++++ tests/WebFetchFactoryTest.php | 63 ++++++++++ tests/WebFetchInjectionFactoryTest.php | 56 +++++++++ tests/WebFetchNewInstanceTest.php | 56 +++++++++ tests/WebQueryBdrTest.php | 113 +++++++++++++++++ 31 files changed, 1314 insertions(+), 3 deletions(-) create mode 100644 src/Exception/InvalidWebEntityException.php create mode 100644 src/Exception/InvalidWebFactoryKeyException.php create mode 100644 src/PostFetchContext.php create mode 100644 src/PostFetchInterface.php create mode 100644 src/WebFetchAssoc.php create mode 100644 src/WebFetchClass.php create mode 100644 src/WebFetchFactory.php create mode 100644 src/WebFetchFactoryInterface.php create mode 100644 src/WebFetchInjectionFactory.php create mode 100644 src/WebFetchInterface.php create mode 100644 src/WebFetchNewInstance.php create mode 100644 src/WebFetchStaticFactory.php create mode 100644 tests/Fake/FakeInjector.php create mode 100644 tests/Fake/FakeNoCtorEntity.php create mode 100644 tests/Fake/FakeProductEntity.php create mode 100644 tests/Fake/FakeProductFactory.php create mode 100644 tests/Fake/FakeProductList.php create mode 100644 tests/Fake/FakeProductModule.php create mode 100644 tests/Fake/FakeStaticProductFactory.php create mode 100644 tests/Fake/FakeTaxCalculator.php create mode 100644 tests/Fake/WebApi/FooProductInterface.php create mode 100644 tests/Fake/web_product.json create mode 100644 tests/PostFetchContextTest.php create mode 100644 tests/WebFetchClassTest.php create mode 100644 tests/WebFetchFactoryTest.php create mode 100644 tests/WebFetchInjectionFactoryTest.php create mode 100644 tests/WebFetchNewInstanceTest.php create mode 100644 tests/WebQueryBdrTest.php 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/InvalidWebEntityException.php b/src/Exception/InvalidWebEntityException.php new file mode 100644 index 0000000..29e0f67 --- /dev/null +++ b/src/Exception/InvalidWebEntityException.php @@ -0,0 +1,28 @@ + $keys Available JSON keys. + */ + public function __construct(string $entity, string $param, array $keys) + { + $available = implode(', ', $keys); + $message = $param !== '' + ? "Entity '{$entity}' requires parameter '{$param}' but it was not found in JSON keys: [{$available}]" + : "Entity class not found: {$entity}"; + parent::__construct($message); + } +} diff --git a/src/Exception/InvalidWebFactoryKeyException.php b/src/Exception/InvalidWebFactoryKeyException.php new file mode 100644 index 0000000..9a179db --- /dev/null +++ b/src/Exception/InvalidWebFactoryKeyException.php @@ -0,0 +1,31 @@ + $availableKeys Available JSON keys. + */ + public function __construct( + string $factoryClass, + string $method, + string $missingParam, + array $availableKeys, + ) { + $available = implode(', ', $availableKeys); + $message = "Factory '{$factoryClass}::{$method}()' requires parameter" + . " '{$missingParam}' but it was not found in JSON keys: [{$available}]"; + parent::__construct($message); + } +} diff --git a/src/MediaQueryWebModule.php b/src/MediaQueryWebModule.php index 49433c4..0db6067 100644 --- a/src/MediaQueryWebModule.php +++ b/src/MediaQueryWebModule.php @@ -8,6 +8,7 @@ use GuzzleHttp\ClientInterface; use Override; 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 +39,10 @@ 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(ReturnEntityInterface::class)->to(ReturnEntity::class); + $this->bind(WebFetchFactoryInterface::class)->to(WebFetchFactory::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 @@ + $row + * @return array + */ + #[Override] + public function fetchRow(array $row, InjectorInterface $injector): array + { + return $row; + } + + /** + * @param array> $rows + * @return array + */ + #[Override] + public function fetchAll(array $rows, InjectorInterface $injector): array + { + return $rows; + } +} diff --git a/src/WebFetchClass.php b/src/WebFetchClass.php new file mode 100644 index 0000000..b80840d --- /dev/null +++ b/src/WebFetchClass.php @@ -0,0 +1,54 @@ + $row */ + #[Override] + public function fetchRow(array $row, InjectorInterface $injector): object + { + /** @var class-string $entity */ + $entity = $this->entity; + /** @psalm-suppress MixedMethodCall */ + $obj = new $entity(); + /** + * @var string $key + * @psalm-suppress MixedAssignment + */ + foreach ($row as $key => $value) { + if (property_exists($obj, $key)) { + $obj->$key = $value; + } + } + + return $obj; + } + + /** + * @param array> $rows + * @return array + */ + #[Override] + public function fetchAll(array $rows, InjectorInterface $injector): array + { + return array_map(fn (array $row): object => $this->fetchRow($row, $injector), $rows); + } +} diff --git a/src/WebFetchFactory.php b/src/WebFetchFactory.php new file mode 100644 index 0000000..1b7c208 --- /dev/null +++ b/src/WebFetchFactory.php @@ -0,0 +1,72 @@ +factory, $this->factoryMethod]; + + if (is_callable($maybeFactory)) { + return new WebFetchStaticFactory($maybeFactory); + } + + if (class_exists($webQuery->factory) && method_exists($webQuery->factory, $this->factoryMethod)) { + return new WebFetchInjectionFactory($maybeFactory, $this->factoryMethod); + } + + if ($entity === null) { + return new WebFetchAssoc(); + } + + if (class_exists($entity) && ! method_exists($entity, '__construct')) { + return new WebFetchClass($entity); + } + + if (! class_exists($entity)) { + throw new InvalidWebEntityException($entity, '', []); + } + + return new WebFetchNewInstance($entity); + } +} diff --git a/src/WebFetchFactoryInterface.php b/src/WebFetchFactoryInterface.php new file mode 100644 index 0000000..67585bf --- /dev/null +++ b/src/WebFetchFactoryInterface.php @@ -0,0 +1,19 @@ + $row */ + #[Override] + public function fetchRow(array $row, InjectorInterface $injector): mixed + { + return $this->callFactory($row, $injector); + } + + /** + * @param array> $rows + * @return array + */ + #[Override] + public function fetchAll(array $rows, InjectorInterface $injector): array + { + return array_map(fn (array $row): mixed => $this->callFactory($row, $injector), $rows); + } + + /** @param array $row */ + private function callFactory(array $row, InjectorInterface $injector): mixed + { + /** @var class-string $factoryClass */ + $factoryClass = $this->factory[0]; + assert(class_exists($factoryClass)); + + $factory = $injector->getInstance($factoryClass); + $method = $this->factoryMethod; + assert(method_exists($factory, $method)); + + $ref = new ReflectionMethod($factory, $method); + $args = $this->buildArgs($ref, $row); + + /** @psalm-suppress MixedMethodCall */ + return $factory->$method(...$args); + } + + /** + * Build named-argument array filtered by factory method parameter names. + * + * - Extra JSON keys are silently discarded. + * - Missing keys with a default value are omitted (PHP uses the default). + * - Missing keys that are nullable receive null explicitly. + * - Missing required non-nullable keys throw InvalidWebFactoryKeyException. + * + * @param array $row + * @return array + */ + private function buildArgs(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; + } + + throw new InvalidWebFactoryKeyException( + $this->factory[0], + $this->factoryMethod, + $name, + array_keys($row), + ); + } + + return $args; + } +} diff --git a/src/WebFetchInterface.php b/src/WebFetchInterface.php new file mode 100644 index 0000000..50be994 --- /dev/null +++ b/src/WebFetchInterface.php @@ -0,0 +1,25 @@ + $row + */ + public function fetchRow(array $row, InjectorInterface $injector): mixed; + + /** + * Build an array of objects from a list of associative rows. + * + * @param array> $rows + * @return array + */ + public function fetchAll(array $rows, InjectorInterface $injector): array; +} diff --git a/src/WebFetchNewInstance.php b/src/WebFetchNewInstance.php new file mode 100644 index 0000000..aeec7e9 --- /dev/null +++ b/src/WebFetchNewInstance.php @@ -0,0 +1,88 @@ + $row */ + #[Override] + public function fetchRow(array $row, InjectorInterface $injector): object + { + $entity = $this->entity; + $ref = new ReflectionClass($entity); + $ctor = $ref->getConstructor(); + $args = $ctor !== null ? $this->buildArgs($ctor, $row) : []; + + /** @psalm-suppress MixedMethodCall */ + return new $entity(...$args); + } + + /** + * @param array> $rows + * @return array + */ + #[Override] + public function fetchAll(array $rows, InjectorInterface $injector): array + { + return array_map(fn (array $row): object => $this->fetchRow($row, $injector), $rows); + } + + /** + * Build a named-argument array filtered by the constructor parameter names. + * + * - Extra JSON keys are silently discarded. + * - Missing keys with a default value are omitted (PHP uses the default). + * - Missing keys that are nullable receive null explicitly. + * - Missing required non-nullable keys throw InvalidWebEntityException. + * + * @param array $row + * @return array + */ + private function buildArgs(ReflectionMethod $ctor, array $row): array + { + $args = []; + foreach ($ctor->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; + } + + throw new InvalidWebEntityException($this->entity, $name, array_keys($row)); + } + + return $args; + } +} diff --git a/src/WebFetchStaticFactory.php b/src/WebFetchStaticFactory.php new file mode 100644 index 0000000..615f386 --- /dev/null +++ b/src/WebFetchStaticFactory.php @@ -0,0 +1,90 @@ +staticFactory = $staticFactory; + } + + /** @param array $row */ + #[Override] + public function fetchRow(array $row, InjectorInterface $injector): mixed + { + $callable = $this->staticFactory; + $args = $this->buildArgs($row); + + return $callable(...$args); + } + + /** + * @param array> $rows + * @return array + */ + #[Override] + public function fetchAll(array $rows, InjectorInterface $injector): array + { + return array_map(fn (array $row): mixed => $this->fetchRow($row, $injector), $rows); + } + + /** + * Build named-argument array from the static method's parameter list. + * + * @param array $row + * @return array + */ + private function buildArgs(array $row): array + { + /** @var array{0: class-string, 1: string} $callable */ + $callable = $this->staticFactory; + $ref = new ReflectionMethod($callable[0], $callable[1]); + $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; + } + + throw new InvalidWebFactoryKeyException( + $callable[0], + $callable[1], + $name, + array_keys($row), + ); + } + + return $args; + } +} diff --git a/src/WebQueryInterceptor.php b/src/WebQueryInterceptor.php index 2491756..bf2ae71 100644 --- a/src/WebQueryInterceptor.php +++ b/src/WebQueryInterceptor.php @@ -8,12 +8,17 @@ use Psr\Http\Message\MessageInterface; use Ray\Aop\MethodInterceptor; use Ray\Aop\MethodInvocation; +use Ray\Di\InjectorInterface; use Ray\MediaQuery\Annotation\Qualifier\WebApiList; use Ray\MediaQuery\Annotation\WebQuery; use Ray\MediaQuery\Exception\NotSupportedReturnTypeException; use ReflectionNamedType; +use ReflectionUnionType; +use function array_is_list; +use function class_exists; use function is_a; +use function is_array; final class WebQueryInterceptor implements MethodInterceptor { @@ -23,12 +28,14 @@ public function __construct( private ParamInjectorInterface $paramInjector, #[WebApiList] private array $webApiList, + private ReturnEntityInterface $returnEntity, + private WebFetchFactoryInterface $webFetchFactory, + private InjectorInterface $injector, ) { } - /** @return array|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 +43,113 @@ 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); + + $resolvedType = $returnType instanceof ReflectionUnionType + ? $returnType + : ($returnType instanceof ReflectionNamedType ? $returnType : null); + $fetch = $this->webFetchFactory->factory($webQuery, $entity, $resolvedType); + + if ($fetch instanceof WebFetchAssoc) { + return $this->invokeLegacy($returnType, $request, $values); + } + + /** @var array $body */ + $body = $this->webApiQuery->request($request['method'], $request['path'], $values); + + $isPostFetch = $returnType instanceof ReflectionNamedType + && class_exists($returnType->getName()) + && is_a($returnType->getName(), PostFetchInterface::class, true); + + $isRow = $webQuery->type === 'row' + || (! $isPostFetch && ( + $returnType instanceof ReflectionUnionType + || ($returnType instanceof ReflectionNamedType && $returnType->getName() !== 'array') + )); + + /** @psalm-suppress MixedAssignment */ + $result = $isRow + ? $this->doFetchRow($body, $fetch) + : $this->doFetchAll($body, $fetch); + + if ($returnType instanceof ReflectionNamedType) { + $typeName = $returnType->getName(); + if (class_exists($typeName) && is_a($typeName, PostFetchInterface::class, true)) { + $context = new PostFetchContext($result, $values, $webQuery); + + return $typeName::fromContext($context); + } + } + + return $result; + } + + /** + * Handle a row (single object) fetch. + * + * Accepts either a top-level associative array (single object) or a list + * whose first element is used. + * + * @param array $body + */ + private function doFetchRow(array $body, WebFetchInterface $fetch): mixed + { + if ($body === []) { + return null; + } + + if (array_is_list($body)) { + if (! isset($body[0])) { + return null; + } + + /** @var array $first */ + $first = $body[0]; + + return $fetch->fetchRow($first, $this->injector); + } + + /** @var array $body */ + return $fetch->fetchRow($body, $this->injector); + } + + /** + * Handle a row_list (multiple objects) fetch. + * + * When the top-level JSON is a single object (not a list), it is wrapped in + * a one-element array so callers always receive a list. + * + * @param array $body + * @return array + */ + private function doFetchAll(array $body, WebFetchInterface $fetch): array + { + if ($body === []) { + return []; + } + + if (! array_is_list($body)) { + /** @var array $body */ + return [$fetch->fetchRow($body, $this->injector)]; + } + + /** @var array> $body */ + return $fetch->fetchAll($body, $this->injector); + } + + /** + * Legacy path: array / string / MessageInterface — no changes from original. + * + * @param array{method: string, path: string} $request + * @param array $values + * @return array|string|MessageInterface + */ + private function invokeLegacy( + mixed $returnType, + array $request, + array $values, + ): array|string|MessageInterface { if ( $returnType instanceof ReflectionNamedType && is_a($returnType->getName(), MessageInterface::class, true) 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/FakeNoCtorEntity.php b/tests/Fake/FakeNoCtorEntity.php new file mode 100644 index 0000000..fddcdd0 --- /dev/null +++ b/tests/Fake/FakeNoCtorEntity.php @@ -0,0 +1,14 @@ +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 built via no-constructor class (WebFetchClass path). */ + #[WebQuery(id: 'foo_product', type: 'row')] + public function getNoCtor(string $id): FakeNoCtorEntity; + + /** Returns an entity via injected instance factory (WebFetchInjectionFactory path). */ + #[WebQuery(id: 'foo_product', type: 'row', factory: FakeProductFactory::class)] + public function getWithTax(string $id): FakeProductEntity; + + /** Returns a list via injected instance factory. */ + /** @return array */ + #[WebQuery(id: 'foo_product', type: 'row_list', factory: FakeProductFactory::class)] + public function listWithTax(string $status): array; + + /** Returns an entity via static factory (WebFetchStaticFactory path). */ + #[WebQuery(id: 'foo_product', type: 'row', factory: FakeStaticProductFactory::class)] + public function getStatic(string $id): FakeProductEntity; + + /** 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/WebFetchClassTest.php b/tests/WebFetchClassTest.php new file mode 100644 index 0000000..4485bfd --- /dev/null +++ b/tests/WebFetchClassTest.php @@ -0,0 +1,34 @@ +fetchRow(['name' => 'Widget', 'price' => 100], $injector); + $this->assertInstanceOf(FakeNoCtorEntity::class, $result); + $this->assertSame('Widget', $result->name); + $this->assertSame(100, $result->price); + } + + public function testFetchAllReturnsMultipleObjects(): void + { + $fetch = new WebFetchClass(FakeNoCtorEntity::class); + $injector = new FakeInjector(); + $rows = [ + ['name' => 'Widget', 'price' => 100], + ['name' => 'Gadget', 'price' => 200], + ]; + /** @var array $result */ + $result = $fetch->fetchAll($rows, $injector); + $this->assertCount(2, $result); + $this->assertSame('Gadget', $result[1]->name); + } +} diff --git a/tests/WebFetchFactoryTest.php b/tests/WebFetchFactoryTest.php new file mode 100644 index 0000000..7361af0 --- /dev/null +++ b/tests/WebFetchFactoryTest.php @@ -0,0 +1,63 @@ +factory = new WebFetchFactory('factory'); + } + + public function testReturnsWebFetchAssocWhenNoFactoryNoEntity(): void + { + $webQuery = new WebQuery('id'); + $fetch = $this->factory->factory($webQuery, null, null); + $this->assertInstanceOf(WebFetchAssoc::class, $fetch); + } + + public function testReturnsWebFetchNewInstanceForEntityWithConstructor(): void + { + $webQuery = new WebQuery('id'); + $fetch = $this->factory->factory($webQuery, FakeProductEntity::class, null); + $this->assertInstanceOf(WebFetchNewInstance::class, $fetch); + } + + public function testReturnsWebFetchClassForEntityWithoutConstructor(): void + { + $webQuery = new WebQuery('id'); + $fetch = $this->factory->factory($webQuery, FakeNoCtorEntity::class, null); + $this->assertInstanceOf(WebFetchClass::class, $fetch); + } + + public function testReturnsWebFetchInjectionFactoryForInstanceFactory(): void + { + $webQuery = new WebQuery(id: 'id', factory: FakeProductFactory::class); + $fetch = $this->factory->factory($webQuery, null, null); + $this->assertInstanceOf(WebFetchInjectionFactory::class, $fetch); + } + + public function testReturnsWebFetchStaticFactoryForStaticFactory(): void + { + $webQuery = new WebQuery(id: 'id', factory: FakeStaticProductFactory::class); + $fetch = $this->factory->factory($webQuery, null, null); + $this->assertInstanceOf(WebFetchStaticFactory::class, $fetch); + } + + public function testThrowsForNonExistentEntityClass(): void + { + $webQuery = new WebQuery('id'); + $this->expectException(InvalidWebEntityException::class); + /** @var class-string $nonExistent */ + $nonExistent = 'NonExistent\\Entity'; + $this->factory->factory($webQuery, $nonExistent, null); + } +} diff --git a/tests/WebFetchInjectionFactoryTest.php b/tests/WebFetchInjectionFactoryTest.php new file mode 100644 index 0000000..eff89d7 --- /dev/null +++ b/tests/WebFetchInjectionFactoryTest.php @@ -0,0 +1,56 @@ +bind($productFactory); + + $fetch = new WebFetchInjectionFactory([FakeProductFactory::class, 'factory'], 'factory'); + $result = $fetch->fetchRow(['name' => 'Widget', 'price' => 100], $injector); + + $this->assertInstanceOf(FakeProductEntity::class, $result); + // FakeTaxCalculator applies *1.1: 100 -> 110 + /** @var FakeProductEntity $result */ + $this->assertSame(110, $result->price); + } + + public function testFetchAllAppliesBusinessLogicToAllRows(): void + { + $productFactory = new FakeProductFactory(new FakeTaxCalculator()); + $injector = (new FakeInjector())->bind($productFactory); + + $fetch = new WebFetchInjectionFactory([FakeProductFactory::class, 'factory'], 'factory'); + $rows = [ + ['name' => 'Widget', 'price' => 100], + ['name' => 'Gadget', 'price' => 200], + ]; + /** @var array $result */ + $result = $fetch->fetchAll($rows, $injector); + + $this->assertCount(2, $result); + $this->assertSame(110, $result[0]->price); + $this->assertSame(220, $result[1]->price); + } + + public function testExtraJsonKeysAreIgnored(): void + { + $productFactory = new FakeProductFactory(new FakeTaxCalculator()); + $injector = (new FakeInjector())->bind($productFactory); + + $fetch = new WebFetchInjectionFactory([FakeProductFactory::class, 'factory'], 'factory'); + $row = ['name' => 'Widget', 'price' => 100, 'extra' => 'ignored']; + $result = $fetch->fetchRow($row, $injector); + $this->assertInstanceOf(FakeProductEntity::class, $result); + /** @var FakeProductEntity $result */ + $this->assertSame('Widget', $result->name); + } +} diff --git a/tests/WebFetchNewInstanceTest.php b/tests/WebFetchNewInstanceTest.php new file mode 100644 index 0000000..21556a7 --- /dev/null +++ b/tests/WebFetchNewInstanceTest.php @@ -0,0 +1,56 @@ +injector = new FakeInjector(); + } + + public function testFetchRow(): void + { + $fetch = new WebFetchNewInstance(FakeProductEntity::class); + $result = $fetch->fetchRow(['name' => 'Widget', 'price' => 100], $this->injector); + $this->assertInstanceOf(FakeProductEntity::class, $result); + $this->assertSame('Widget', $result->name); + $this->assertSame(100, $result->price); + } + + public function testFetchAll(): void + { + $fetch = new WebFetchNewInstance(FakeProductEntity::class); + $rows = [ + ['name' => 'Widget', 'price' => 100], + ['name' => 'Gadget', 'price' => 200], + ]; + $result = $fetch->fetchAll($rows, $this->injector); + $this->assertCount(2, $result); + $this->assertInstanceOf(FakeProductEntity::class, $result[0]); + $this->assertSame('Gadget', $result[1]->name); + } + + public function testExtraJsonKeysAreIgnored(): void + { + $fetch = new WebFetchNewInstance(FakeProductEntity::class); + $row = ['name' => 'Widget', 'price' => 100, 'extra' => 'ignored']; + $result = $fetch->fetchRow($row, $this->injector); + $this->assertInstanceOf(FakeProductEntity::class, $result); + $this->assertSame('Widget', $result->name); + } + + public function testMissingRequiredKeyThrows(): void + { + $fetch = new WebFetchNewInstance(FakeProductEntity::class); + $this->expectException(InvalidWebEntityException::class); + $fetch->fetchRow(['name' => 'Widget'], $this->injector); + } +} diff --git a/tests/WebQueryBdrTest.php b/tests/WebQueryBdrTest.php new file mode 100644 index 0000000..50a1288 --- /dev/null +++ b/tests/WebQueryBdrTest.php @@ -0,0 +1,113 @@ + '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 testEntityWithoutConstructor(): void + { + $body = '{"name":"Widget","price":100}'; + $this->fooProduct = $this->buildFooProduct($body); + $result = $this->fooProduct->getNoCtor('1'); + $this->assertInstanceOf(FakeNoCtorEntity::class, $result); + $this->assertSame('Widget', $result->name); + } + + 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 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']); + } +} From dc368fe7615079a8a219b5bda2bef2d048b6048f Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 09:14:36 +0900 Subject: [PATCH 02/10] Document BDR factory and PostFetch in README --- README.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/README.md b/README.md index 717e358..accb407 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,129 @@ 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 — or through public properties if the +class has no 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 From cda875b5a81fab61a6103c95e9a54f68d82e0d22 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 09:21:33 +0900 Subject: [PATCH 03/10] Drop WebFetchClass in favor of constructor-only hydration WebFetchClass mirrored PDO::FETCH_CLASS (property assignment for entities without a constructor), which existed in ray/media-query for historical reasons. The Web layer maps JSON by hand, so that path adds no value: constructor hydration via named arguments is type-safe, supports readonly promotion, and matches the immutable intent of the BDR read model. Entities are now always built through their constructor (WebFetchNewInstance). Remove the no-constructor branch from WebFetchFactory and the related fake and tests. --- README.md | 3 +- src/WebFetchClass.php | 54 ----------------------- src/WebFetchFactory.php | 9 +--- tests/Fake/FakeNoCtorEntity.php | 14 ------ tests/Fake/WebApi/FooProductInterface.php | 5 --- tests/WebFetchClassTest.php | 34 -------------- tests/WebFetchFactoryTest.php | 7 --- tests/WebQueryBdrTest.php | 9 ---- 8 files changed, 3 insertions(+), 132 deletions(-) delete mode 100644 src/WebFetchClass.php delete mode 100644 tests/Fake/FakeNoCtorEntity.php delete mode 100644 tests/WebFetchClassTest.php diff --git a/README.md b/README.md index accb407..0efa5f4 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,7 @@ 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 — or through public properties if the -class has no constructor. +hydrated through the entity constructor. ```php #[WebQuery('product_item', type: 'row')] diff --git a/src/WebFetchClass.php b/src/WebFetchClass.php deleted file mode 100644 index b80840d..0000000 --- a/src/WebFetchClass.php +++ /dev/null @@ -1,54 +0,0 @@ - $row */ - #[Override] - public function fetchRow(array $row, InjectorInterface $injector): object - { - /** @var class-string $entity */ - $entity = $this->entity; - /** @psalm-suppress MixedMethodCall */ - $obj = new $entity(); - /** - * @var string $key - * @psalm-suppress MixedAssignment - */ - foreach ($row as $key => $value) { - if (property_exists($obj, $key)) { - $obj->$key = $value; - } - } - - return $obj; - } - - /** - * @param array> $rows - * @return array - */ - #[Override] - public function fetchAll(array $rows, InjectorInterface $injector): array - { - return array_map(fn (array $row): object => $this->fetchRow($row, $injector), $rows); - } -} diff --git a/src/WebFetchFactory.php b/src/WebFetchFactory.php index 1b7c208..a270315 100644 --- a/src/WebFetchFactory.php +++ b/src/WebFetchFactory.php @@ -23,9 +23,8 @@ * 1. static callable → WebFetchStaticFactory * 2. instance factory → WebFetchInjectionFactory * 3. no entity → WebFetchAssoc (sentinel for legacy path) - * 4. entity, no ctor → WebFetchClass - * 5. entity missing → InvalidWebEntityException - * 6. entity with ctor → WebFetchNewInstance + * 4. entity missing → InvalidWebEntityException + * 5. entity → WebFetchNewInstance */ final class WebFetchFactory implements WebFetchFactoryInterface { @@ -59,10 +58,6 @@ public function factory( return new WebFetchAssoc(); } - if (class_exists($entity) && ! method_exists($entity, '__construct')) { - return new WebFetchClass($entity); - } - if (! class_exists($entity)) { throw new InvalidWebEntityException($entity, '', []); } diff --git a/tests/Fake/FakeNoCtorEntity.php b/tests/Fake/FakeNoCtorEntity.php deleted file mode 100644 index fddcdd0..0000000 --- a/tests/Fake/FakeNoCtorEntity.php +++ /dev/null @@ -1,14 +0,0 @@ -fetchRow(['name' => 'Widget', 'price' => 100], $injector); - $this->assertInstanceOf(FakeNoCtorEntity::class, $result); - $this->assertSame('Widget', $result->name); - $this->assertSame(100, $result->price); - } - - public function testFetchAllReturnsMultipleObjects(): void - { - $fetch = new WebFetchClass(FakeNoCtorEntity::class); - $injector = new FakeInjector(); - $rows = [ - ['name' => 'Widget', 'price' => 100], - ['name' => 'Gadget', 'price' => 200], - ]; - /** @var array $result */ - $result = $fetch->fetchAll($rows, $injector); - $this->assertCount(2, $result); - $this->assertSame('Gadget', $result[1]->name); - } -} diff --git a/tests/WebFetchFactoryTest.php b/tests/WebFetchFactoryTest.php index 7361af0..8e2fce7 100644 --- a/tests/WebFetchFactoryTest.php +++ b/tests/WebFetchFactoryTest.php @@ -31,13 +31,6 @@ public function testReturnsWebFetchNewInstanceForEntityWithConstructor(): void $this->assertInstanceOf(WebFetchNewInstance::class, $fetch); } - public function testReturnsWebFetchClassForEntityWithoutConstructor(): void - { - $webQuery = new WebQuery('id'); - $fetch = $this->factory->factory($webQuery, FakeNoCtorEntity::class, null); - $this->assertInstanceOf(WebFetchClass::class, $fetch); - } - public function testReturnsWebFetchInjectionFactoryForInstanceFactory(): void { $webQuery = new WebQuery(id: 'id', factory: FakeProductFactory::class); diff --git a/tests/WebQueryBdrTest.php b/tests/WebQueryBdrTest.php index 50a1288..ea864d9 100644 --- a/tests/WebQueryBdrTest.php +++ b/tests/WebQueryBdrTest.php @@ -35,15 +35,6 @@ public function testEntityWithConstructor(): void $this->assertSame(100, $result->price); } - public function testEntityWithoutConstructor(): void - { - $body = '{"name":"Widget","price":100}'; - $this->fooProduct = $this->buildFooProduct($body); - $result = $this->fooProduct->getNoCtor('1'); - $this->assertInstanceOf(FakeNoCtorEntity::class, $result); - $this->assertSame('Widget', $result->name); - } - public function testInjectedFactoryAppliesTax(): void { $body = '{"name":"Widget","price":100}'; From 2d728d3ccc48640ab37fa385001f25db2181f60b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 09:30:35 +0900 Subject: [PATCH 04/10] Reject entities without a constructor explicitly WebFetchNewInstance fell back to `new $entity()` when the class had no constructor, silently producing an object that drops the response data. With constructor-only hydration that fallback is a footgun: throw EntityWithoutConstructorException instead so the misconfiguration surfaces immediately. --- .../EntityWithoutConstructorException.php | 20 +++++++++++++++++++ src/WebFetchNewInstance.php | 15 +++++++++----- tests/WebFetchNewInstanceTest.php | 11 ++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/Exception/EntityWithoutConstructorException.php 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 @@ +entity; - $ref = new ReflectionClass($entity); - $ctor = $ref->getConstructor(); - $args = $ctor !== null ? $this->buildArgs($ctor, $row) : []; + $ctor = (new ReflectionClass($entity))->getConstructor(); + if ($ctor === null) { + throw new EntityWithoutConstructorException($entity); + } + + $args = $this->buildArgs($ctor, $row); /** @psalm-suppress MixedMethodCall */ return new $entity(...$args); diff --git a/tests/WebFetchNewInstanceTest.php b/tests/WebFetchNewInstanceTest.php index 21556a7..fc04125 100644 --- a/tests/WebFetchNewInstanceTest.php +++ b/tests/WebFetchNewInstanceTest.php @@ -5,6 +5,7 @@ namespace Ray\MediaQuery; use PHPUnit\Framework\TestCase; +use Ray\MediaQuery\Exception\EntityWithoutConstructorException; use Ray\MediaQuery\Exception\InvalidWebEntityException; class WebFetchNewInstanceTest extends TestCase @@ -53,4 +54,14 @@ public function testMissingRequiredKeyThrows(): void $this->expectException(InvalidWebEntityException::class); $fetch->fetchRow(['name' => 'Widget'], $this->injector); } + + public function testThrowsWhenEntityHasNoConstructor(): void + { + $noCtor = new class { + public string $name = ''; + }; + $fetch = new WebFetchNewInstance($noCtor::class); + $this->expectException(EntityWithoutConstructorException::class); + $fetch->fetchRow(['name' => 'Widget'], $this->injector); + } } From e63fe1f9d4553c11a8bf93050f9fa6986187534b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 09:52:11 +0900 Subject: [PATCH 05/10] Replace PDO-shaped fetch classes with a single web-native mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The factory layer was ported too literally from ray/media-query's PDO FetchFactory: a strategy class per fetch mode (WebFetchClass/Assoc/NewInstance/ StaticFactory/InjectionFactory behind WebFetchInterface + WebFetchFactory). A web response is already a decoded array — there is no PDOStatement and no fetch modes — so that hierarchy was accidental complexity, and the key-to- argument binding was triplicated across three of the classes. Collapse it into one WebResponseMapper: a single converter (static factory, DI-resolved instance factory, or entity constructor) turns one response row into one object, applied to a row or a list. Argument binding lives in one place. The interceptor now decides the legacy (array/string/Message) path itself instead of a hollow WebFetchAssoc sentinel, and an unresolvable factory raises InvalidWebFactoryException instead of silently falling back to a raw array. Public API and behaviour are unchanged; the WebQueryBdrTest integration suite covers every path. Consolidate the duplicated key exceptions into MissingResponseKeyException. --- src/Exception/InvalidWebEntityException.php | 18 +- src/Exception/InvalidWebFactoryException.php | 19 ++ .../InvalidWebFactoryKeyException.php | 31 --- src/Exception/MissingResponseKeyException.php | 27 +++ src/MediaQueryWebModule.php | 2 +- src/WebFetchAssoc.php | 37 ---- src/WebFetchFactory.php | 67 ------- src/WebFetchFactoryInterface.php | 19 -- src/WebFetchInjectionFactory.php | 109 ---------- src/WebFetchInterface.php | 25 --- src/WebFetchNewInstance.php | 93 --------- src/WebFetchStaticFactory.php | 90 --------- src/WebQueryInterceptor.php | 104 ++++------ src/WebResponseMapper.php | 187 ++++++++++++++++++ src/WebResponseMapperInterface.php | 18 ++ tests/Fake/WebApi/FooProductInterface.php | 10 +- tests/WebFetchFactoryTest.php | 56 ------ tests/WebFetchInjectionFactoryTest.php | 56 ------ tests/WebFetchNewInstanceTest.php | 67 ------- tests/WebResponseMapperTest.php | 129 ++++++++++++ 20 files changed, 424 insertions(+), 740 deletions(-) create mode 100644 src/Exception/InvalidWebFactoryException.php delete mode 100644 src/Exception/InvalidWebFactoryKeyException.php create mode 100644 src/Exception/MissingResponseKeyException.php delete mode 100644 src/WebFetchAssoc.php delete mode 100644 src/WebFetchFactory.php delete mode 100644 src/WebFetchFactoryInterface.php delete mode 100644 src/WebFetchInjectionFactory.php delete mode 100644 src/WebFetchInterface.php delete mode 100644 src/WebFetchNewInstance.php delete mode 100644 src/WebFetchStaticFactory.php create mode 100644 src/WebResponseMapper.php create mode 100644 src/WebResponseMapperInterface.php delete mode 100644 tests/WebFetchFactoryTest.php delete mode 100644 tests/WebFetchInjectionFactoryTest.php delete mode 100644 tests/WebFetchNewInstanceTest.php create mode 100644 tests/WebResponseMapperTest.php diff --git a/src/Exception/InvalidWebEntityException.php b/src/Exception/InvalidWebEntityException.php index 29e0f67..095feca 100644 --- a/src/Exception/InvalidWebEntityException.php +++ b/src/Exception/InvalidWebEntityException.php @@ -4,25 +4,13 @@ namespace Ray\MediaQuery\Exception; -use function implode; - /** - * Thrown when a required constructor parameter is missing from the JSON row, - * or when the entity class does not exist. + * Thrown when the return-type entity class cannot be found. */ final class InvalidWebEntityException extends LogicException { - /** - * @param string $entity Entity class name. - * @param string $param Missing parameter name (empty when class not found). - * @param list $keys Available JSON keys. - */ - public function __construct(string $entity, string $param, array $keys) + public function __construct(string $entity) { - $available = implode(', ', $keys); - $message = $param !== '' - ? "Entity '{$entity}' requires parameter '{$param}' but it was not found in JSON keys: [{$available}]" - : "Entity class not found: {$entity}"; - parent::__construct($message); + parent::__construct("Entity class not found: {$entity}"); } } diff --git a/src/Exception/InvalidWebFactoryException.php b/src/Exception/InvalidWebFactoryException.php new file mode 100644 index 0000000..03938b7 --- /dev/null +++ b/src/Exception/InvalidWebFactoryException.php @@ -0,0 +1,19 @@ + $availableKeys Available JSON keys. - */ - public function __construct( - string $factoryClass, - string $method, - string $missingParam, - array $availableKeys, - ) { - $available = implode(', ', $availableKeys); - $message = "Factory '{$factoryClass}::{$method}()' requires parameter" - . " '{$missingParam}' but it was not found in JSON keys: [{$available}]"; - parent::__construct($message); - } -} diff --git a/src/Exception/MissingResponseKeyException.php b/src/Exception/MissingResponseKeyException.php new file mode 100644 index 0000000..fa5f797 --- /dev/null +++ b/src/Exception/MissingResponseKeyException.php @@ -0,0 +1,27 @@ + $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 0db6067..272dad6 100644 --- a/src/MediaQueryWebModule.php +++ b/src/MediaQueryWebModule.php @@ -42,7 +42,7 @@ public function configure(): void // BDR factory layer $this->bind(ReturnEntityInterface::class)->to(ReturnEntity::class); - $this->bind(WebFetchFactoryInterface::class)->to(WebFetchFactory::class); + $this->bind(WebResponseMapperInterface::class)->to(WebResponseMapper::class); $this->bind()->annotatedWith(FactoryMethod::class)->toInstance('factory'); } } diff --git a/src/WebFetchAssoc.php b/src/WebFetchAssoc.php deleted file mode 100644 index a4ade56..0000000 --- a/src/WebFetchAssoc.php +++ /dev/null @@ -1,37 +0,0 @@ - $row - * @return array - */ - #[Override] - public function fetchRow(array $row, InjectorInterface $injector): array - { - return $row; - } - - /** - * @param array> $rows - * @return array - */ - #[Override] - public function fetchAll(array $rows, InjectorInterface $injector): array - { - return $rows; - } -} diff --git a/src/WebFetchFactory.php b/src/WebFetchFactory.php deleted file mode 100644 index a270315..0000000 --- a/src/WebFetchFactory.php +++ /dev/null @@ -1,67 +0,0 @@ -factory, $this->factoryMethod]; - - if (is_callable($maybeFactory)) { - return new WebFetchStaticFactory($maybeFactory); - } - - if (class_exists($webQuery->factory) && method_exists($webQuery->factory, $this->factoryMethod)) { - return new WebFetchInjectionFactory($maybeFactory, $this->factoryMethod); - } - - if ($entity === null) { - return new WebFetchAssoc(); - } - - if (! class_exists($entity)) { - throw new InvalidWebEntityException($entity, '', []); - } - - return new WebFetchNewInstance($entity); - } -} diff --git a/src/WebFetchFactoryInterface.php b/src/WebFetchFactoryInterface.php deleted file mode 100644 index 67585bf..0000000 --- a/src/WebFetchFactoryInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - $row */ - #[Override] - public function fetchRow(array $row, InjectorInterface $injector): mixed - { - return $this->callFactory($row, $injector); - } - - /** - * @param array> $rows - * @return array - */ - #[Override] - public function fetchAll(array $rows, InjectorInterface $injector): array - { - return array_map(fn (array $row): mixed => $this->callFactory($row, $injector), $rows); - } - - /** @param array $row */ - private function callFactory(array $row, InjectorInterface $injector): mixed - { - /** @var class-string $factoryClass */ - $factoryClass = $this->factory[0]; - assert(class_exists($factoryClass)); - - $factory = $injector->getInstance($factoryClass); - $method = $this->factoryMethod; - assert(method_exists($factory, $method)); - - $ref = new ReflectionMethod($factory, $method); - $args = $this->buildArgs($ref, $row); - - /** @psalm-suppress MixedMethodCall */ - return $factory->$method(...$args); - } - - /** - * Build named-argument array filtered by factory method parameter names. - * - * - Extra JSON keys are silently discarded. - * - Missing keys with a default value are omitted (PHP uses the default). - * - Missing keys that are nullable receive null explicitly. - * - Missing required non-nullable keys throw InvalidWebFactoryKeyException. - * - * @param array $row - * @return array - */ - private function buildArgs(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; - } - - throw new InvalidWebFactoryKeyException( - $this->factory[0], - $this->factoryMethod, - $name, - array_keys($row), - ); - } - - return $args; - } -} diff --git a/src/WebFetchInterface.php b/src/WebFetchInterface.php deleted file mode 100644 index 50be994..0000000 --- a/src/WebFetchInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - $row - */ - public function fetchRow(array $row, InjectorInterface $injector): mixed; - - /** - * Build an array of objects from a list of associative rows. - * - * @param array> $rows - * @return array - */ - public function fetchAll(array $rows, InjectorInterface $injector): array; -} diff --git a/src/WebFetchNewInstance.php b/src/WebFetchNewInstance.php deleted file mode 100644 index 5d82a1d..0000000 --- a/src/WebFetchNewInstance.php +++ /dev/null @@ -1,93 +0,0 @@ - $row */ - #[Override] - public function fetchRow(array $row, InjectorInterface $injector): object - { - $entity = $this->entity; - $ctor = (new ReflectionClass($entity))->getConstructor(); - if ($ctor === null) { - throw new EntityWithoutConstructorException($entity); - } - - $args = $this->buildArgs($ctor, $row); - - /** @psalm-suppress MixedMethodCall */ - return new $entity(...$args); - } - - /** - * @param array> $rows - * @return array - */ - #[Override] - public function fetchAll(array $rows, InjectorInterface $injector): array - { - return array_map(fn (array $row): object => $this->fetchRow($row, $injector), $rows); - } - - /** - * Build a named-argument array filtered by the constructor parameter names. - * - * - Extra JSON keys are silently discarded. - * - Missing keys with a default value are omitted (PHP uses the default). - * - Missing keys that are nullable receive null explicitly. - * - Missing required non-nullable keys throw InvalidWebEntityException. - * - * @param array $row - * @return array - */ - private function buildArgs(ReflectionMethod $ctor, array $row): array - { - $args = []; - foreach ($ctor->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; - } - - throw new InvalidWebEntityException($this->entity, $name, array_keys($row)); - } - - return $args; - } -} diff --git a/src/WebFetchStaticFactory.php b/src/WebFetchStaticFactory.php deleted file mode 100644 index 615f386..0000000 --- a/src/WebFetchStaticFactory.php +++ /dev/null @@ -1,90 +0,0 @@ -staticFactory = $staticFactory; - } - - /** @param array $row */ - #[Override] - public function fetchRow(array $row, InjectorInterface $injector): mixed - { - $callable = $this->staticFactory; - $args = $this->buildArgs($row); - - return $callable(...$args); - } - - /** - * @param array> $rows - * @return array - */ - #[Override] - public function fetchAll(array $rows, InjectorInterface $injector): array - { - return array_map(fn (array $row): mixed => $this->fetchRow($row, $injector), $rows); - } - - /** - * Build named-argument array from the static method's parameter list. - * - * @param array $row - * @return array - */ - private function buildArgs(array $row): array - { - /** @var array{0: class-string, 1: string} $callable */ - $callable = $this->staticFactory; - $ref = new ReflectionMethod($callable[0], $callable[1]); - $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; - } - - throw new InvalidWebFactoryKeyException( - $callable[0], - $callable[1], - $name, - array_keys($row), - ); - } - - return $args; - } -} diff --git a/src/WebQueryInterceptor.php b/src/WebQueryInterceptor.php index bf2ae71..55f87ae 100644 --- a/src/WebQueryInterceptor.php +++ b/src/WebQueryInterceptor.php @@ -8,17 +8,15 @@ use Psr\Http\Message\MessageInterface; use Ray\Aop\MethodInterceptor; use Ray\Aop\MethodInvocation; -use Ray\Di\InjectorInterface; use Ray\MediaQuery\Annotation\Qualifier\WebApiList; use Ray\MediaQuery\Annotation\WebQuery; use Ray\MediaQuery\Exception\NotSupportedReturnTypeException; use ReflectionNamedType; +use ReflectionType; use ReflectionUnionType; -use function array_is_list; use function class_exists; use function is_a; -use function is_array; final class WebQueryInterceptor implements MethodInterceptor { @@ -29,8 +27,7 @@ public function __construct( #[WebApiList] private array $webApiList, private ReturnEntityInterface $returnEntity, - private WebFetchFactoryInterface $webFetchFactory, - private InjectorInterface $injector, + private WebResponseMapperInterface $mapper, ) { } @@ -46,107 +43,76 @@ public function invoke(MethodInvocation $invocation): mixed $returnType = $method->getReturnType(); $entity = ($this->returnEntity)($method); - $resolvedType = $returnType instanceof ReflectionUnionType - ? $returnType - : ($returnType instanceof ReflectionNamedType ? $returnType : null); - $fetch = $this->webFetchFactory->factory($webQuery, $entity, $resolvedType); - - if ($fetch instanceof WebFetchAssoc) { + // Legacy path: no object mapping requested (raw array / string / PSR-7 message). + if ($webQuery->factory === '' && $entity === null) { return $this->invokeLegacy($returnType, $request, $values); } /** @var array $body */ $body = $this->webApiQuery->request($request['method'], $request['path'], $values); - - $isPostFetch = $returnType instanceof ReflectionNamedType - && class_exists($returnType->getName()) - && is_a($returnType->getName(), PostFetchInterface::class, true); - - $isRow = $webQuery->type === 'row' - || (! $isPostFetch && ( - $returnType instanceof ReflectionUnionType - || ($returnType instanceof ReflectionNamedType && $returnType->getName() !== 'array') - )); + $postFetch = $this->postFetchClass($returnType); + $isRow = $this->isRow($webQuery, $returnType, $postFetch !== null); /** @psalm-suppress MixedAssignment */ - $result = $isRow - ? $this->doFetchRow($body, $fetch) - : $this->doFetchAll($body, $fetch); - - if ($returnType instanceof ReflectionNamedType) { - $typeName = $returnType->getName(); - if (class_exists($typeName) && is_a($typeName, PostFetchInterface::class, true)) { - $context = new PostFetchContext($result, $values, $webQuery); + $result = $this->mapper->map($webQuery, $entity, $isRow, $body); - return $typeName::fromContext($context); - } + if ($postFetch !== null) { + return $postFetch::fromContext(new PostFetchContext($result, $values, $webQuery)); } return $result; } /** - * Handle a row (single object) fetch. + * Resolve the return type to a PostFetchInterface class, or null. * - * Accepts either a top-level associative array (single object) or a list - * whose first element is used. - * - * @param array $body + * @return class-string|null */ - private function doFetchRow(array $body, WebFetchInterface $fetch): mixed + private function postFetchClass(ReflectionType|null $returnType): string|null { - if ($body === []) { - return null; - } - - if (array_is_list($body)) { - if (! isset($body[0])) { - return null; - } - - /** @var array $first */ - $first = $body[0]; - - return $fetch->fetchRow($first, $this->injector); + if ( + $returnType instanceof ReflectionNamedType + && class_exists($returnType->getName()) + && is_a($returnType->getName(), PostFetchInterface::class, true) + ) { + /** @var class-string */ + return $returnType->getName(); } - /** @var array $body */ - return $fetch->fetchRow($body, $this->injector); + return null; } /** - * Handle a row_list (multiple objects) fetch. - * - * When the top-level JSON is a single object (not a list), it is wrapped in - * a one-element array so callers always receive a list. - * - * @param array $body - * @return array + * A single object is returned for `type: 'row'`, a union return type, or a + * named (non-array, non-PostFetch) class return type. Otherwise a list. */ - private function doFetchAll(array $body, WebFetchInterface $fetch): array + private function isRow(WebQuery $webQuery, ReflectionType|null $returnType, bool $isPostFetch): bool { - if ($body === []) { - return []; + if ($webQuery->type === 'row') { + return true; + } + + if ($isPostFetch) { + return false; } - if (! array_is_list($body)) { - /** @var array $body */ - return [$fetch->fetchRow($body, $this->injector)]; + if ($returnType instanceof ReflectionUnionType) { + return true; } - /** @var array> $body */ - return $fetch->fetchAll($body, $this->injector); + return $returnType instanceof ReflectionNamedType && $returnType->getName() !== 'array'; } /** - * Legacy path: array / string / MessageInterface — no changes from original. + * Legacy path: array / string / MessageInterface. * * @param array{method: string, path: string} $request * @param array $values + * * @return array|string|MessageInterface */ private function invokeLegacy( - mixed $returnType, + ReflectionType|null $returnType, array $request, array $values, ): array|string|MessageInterface { diff --git a/src/WebResponseMapper.php b/src/WebResponseMapper.php new file mode 100644 index 0000000..ef9bb58 --- /dev/null +++ b/src/WebResponseMapper.php @@ -0,0 +1,187 @@ + $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->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/WebApi/FooProductInterface.php b/tests/Fake/WebApi/FooProductInterface.php index 74d458c..5701e87 100644 --- a/tests/Fake/WebApi/FooProductInterface.php +++ b/tests/Fake/WebApi/FooProductInterface.php @@ -12,25 +12,25 @@ interface FooProductInterface { - /** Returns an entity built via constructor (WebFetchNewInstance path). */ + /** Returns an entity built via its constructor. */ #[WebQuery(id: 'foo_product', type: 'row')] public function get(string $id): FakeProductEntity; - /** Returns a list of entities via constructor (WebFetchNewInstance + fetchAll path). */ + /** Returns a list of entities built via their constructor. */ /** @return array */ #[WebQuery(id: 'foo_product', type: 'row_list')] public function list(string $status): array; - /** Returns an entity via injected instance factory (WebFetchInjectionFactory path). */ + /** 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 injected instance factory. */ + /** 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 static factory (WebFetchStaticFactory path). */ + /** Returns an entity via a static factory. */ #[WebQuery(id: 'foo_product', type: 'row', factory: FakeStaticProductFactory::class)] public function getStatic(string $id): FakeProductEntity; diff --git a/tests/WebFetchFactoryTest.php b/tests/WebFetchFactoryTest.php deleted file mode 100644 index 8e2fce7..0000000 --- a/tests/WebFetchFactoryTest.php +++ /dev/null @@ -1,56 +0,0 @@ -factory = new WebFetchFactory('factory'); - } - - public function testReturnsWebFetchAssocWhenNoFactoryNoEntity(): void - { - $webQuery = new WebQuery('id'); - $fetch = $this->factory->factory($webQuery, null, null); - $this->assertInstanceOf(WebFetchAssoc::class, $fetch); - } - - public function testReturnsWebFetchNewInstanceForEntityWithConstructor(): void - { - $webQuery = new WebQuery('id'); - $fetch = $this->factory->factory($webQuery, FakeProductEntity::class, null); - $this->assertInstanceOf(WebFetchNewInstance::class, $fetch); - } - - public function testReturnsWebFetchInjectionFactoryForInstanceFactory(): void - { - $webQuery = new WebQuery(id: 'id', factory: FakeProductFactory::class); - $fetch = $this->factory->factory($webQuery, null, null); - $this->assertInstanceOf(WebFetchInjectionFactory::class, $fetch); - } - - public function testReturnsWebFetchStaticFactoryForStaticFactory(): void - { - $webQuery = new WebQuery(id: 'id', factory: FakeStaticProductFactory::class); - $fetch = $this->factory->factory($webQuery, null, null); - $this->assertInstanceOf(WebFetchStaticFactory::class, $fetch); - } - - public function testThrowsForNonExistentEntityClass(): void - { - $webQuery = new WebQuery('id'); - $this->expectException(InvalidWebEntityException::class); - /** @var class-string $nonExistent */ - $nonExistent = 'NonExistent\\Entity'; - $this->factory->factory($webQuery, $nonExistent, null); - } -} diff --git a/tests/WebFetchInjectionFactoryTest.php b/tests/WebFetchInjectionFactoryTest.php deleted file mode 100644 index eff89d7..0000000 --- a/tests/WebFetchInjectionFactoryTest.php +++ /dev/null @@ -1,56 +0,0 @@ -bind($productFactory); - - $fetch = new WebFetchInjectionFactory([FakeProductFactory::class, 'factory'], 'factory'); - $result = $fetch->fetchRow(['name' => 'Widget', 'price' => 100], $injector); - - $this->assertInstanceOf(FakeProductEntity::class, $result); - // FakeTaxCalculator applies *1.1: 100 -> 110 - /** @var FakeProductEntity $result */ - $this->assertSame(110, $result->price); - } - - public function testFetchAllAppliesBusinessLogicToAllRows(): void - { - $productFactory = new FakeProductFactory(new FakeTaxCalculator()); - $injector = (new FakeInjector())->bind($productFactory); - - $fetch = new WebFetchInjectionFactory([FakeProductFactory::class, 'factory'], 'factory'); - $rows = [ - ['name' => 'Widget', 'price' => 100], - ['name' => 'Gadget', 'price' => 200], - ]; - /** @var array $result */ - $result = $fetch->fetchAll($rows, $injector); - - $this->assertCount(2, $result); - $this->assertSame(110, $result[0]->price); - $this->assertSame(220, $result[1]->price); - } - - public function testExtraJsonKeysAreIgnored(): void - { - $productFactory = new FakeProductFactory(new FakeTaxCalculator()); - $injector = (new FakeInjector())->bind($productFactory); - - $fetch = new WebFetchInjectionFactory([FakeProductFactory::class, 'factory'], 'factory'); - $row = ['name' => 'Widget', 'price' => 100, 'extra' => 'ignored']; - $result = $fetch->fetchRow($row, $injector); - $this->assertInstanceOf(FakeProductEntity::class, $result); - /** @var FakeProductEntity $result */ - $this->assertSame('Widget', $result->name); - } -} diff --git a/tests/WebFetchNewInstanceTest.php b/tests/WebFetchNewInstanceTest.php deleted file mode 100644 index fc04125..0000000 --- a/tests/WebFetchNewInstanceTest.php +++ /dev/null @@ -1,67 +0,0 @@ -injector = new FakeInjector(); - } - - public function testFetchRow(): void - { - $fetch = new WebFetchNewInstance(FakeProductEntity::class); - $result = $fetch->fetchRow(['name' => 'Widget', 'price' => 100], $this->injector); - $this->assertInstanceOf(FakeProductEntity::class, $result); - $this->assertSame('Widget', $result->name); - $this->assertSame(100, $result->price); - } - - public function testFetchAll(): void - { - $fetch = new WebFetchNewInstance(FakeProductEntity::class); - $rows = [ - ['name' => 'Widget', 'price' => 100], - ['name' => 'Gadget', 'price' => 200], - ]; - $result = $fetch->fetchAll($rows, $this->injector); - $this->assertCount(2, $result); - $this->assertInstanceOf(FakeProductEntity::class, $result[0]); - $this->assertSame('Gadget', $result[1]->name); - } - - public function testExtraJsonKeysAreIgnored(): void - { - $fetch = new WebFetchNewInstance(FakeProductEntity::class); - $row = ['name' => 'Widget', 'price' => 100, 'extra' => 'ignored']; - $result = $fetch->fetchRow($row, $this->injector); - $this->assertInstanceOf(FakeProductEntity::class, $result); - $this->assertSame('Widget', $result->name); - } - - public function testMissingRequiredKeyThrows(): void - { - $fetch = new WebFetchNewInstance(FakeProductEntity::class); - $this->expectException(InvalidWebEntityException::class); - $fetch->fetchRow(['name' => 'Widget'], $this->injector); - } - - public function testThrowsWhenEntityHasNoConstructor(): void - { - $noCtor = new class { - public string $name = ''; - }; - $fetch = new WebFetchNewInstance($noCtor::class); - $this->expectException(EntityWithoutConstructorException::class); - $fetch->fetchRow(['name' => 'Widget'], $this->injector); - } -} diff --git a/tests/WebResponseMapperTest.php b/tests/WebResponseMapperTest.php new file mode 100644 index 0000000..8f50e6a --- /dev/null +++ b/tests/WebResponseMapperTest.php @@ -0,0 +1,129 @@ + '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 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 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); + } +} From 770e07f2997ee2561d331eb21f8ccdb8b03a99f0 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 10:23:41 +0900 Subject: [PATCH 06/10] Rename invokeLegacy to rawResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array / string / PSR-7 message return is a first-class, documented feature, not a deprecated path. Name it for what it does — return the raw response — instead of implying it is legacy. --- src/WebQueryInterceptor.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/WebQueryInterceptor.php b/src/WebQueryInterceptor.php index 55f87ae..692b103 100644 --- a/src/WebQueryInterceptor.php +++ b/src/WebQueryInterceptor.php @@ -43,9 +43,9 @@ public function invoke(MethodInvocation $invocation): mixed $returnType = $method->getReturnType(); $entity = ($this->returnEntity)($method); - // Legacy path: no object mapping requested (raw array / string / PSR-7 message). + // No object mapping requested: return the raw array / string / PSR-7 message. if ($webQuery->factory === '' && $entity === null) { - return $this->invokeLegacy($returnType, $request, $values); + return $this->rawResponse($returnType, $request, $values); } /** @var array $body */ @@ -104,14 +104,15 @@ private function isRow(WebQuery $webQuery, ReflectionType|null $returnType, bool } /** - * Legacy path: array / string / MessageInterface. + * 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 invokeLegacy( + private function rawResponse( ReflectionType|null $returnType, array $request, array $values, From 46450d1474f5fb8034bf725f3102d130228da180 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 12:32:31 +0900 Subject: [PATCH 07/10] Bind DocBlockFactoryInterface in the web module ReturnEntity requires phpDocumentor\Reflection\DocBlockFactoryInterface in ray/media-query ^1.1, but MediaQueryWebModule never bound it, so #[WebQuery] interception failed with an Unbound error on the highest dependency build. Bind it the way MediaQueryDbModule does and declare phpdocumentor/reflection-docblock as a direct dependency. Also reject non-public factory methods in WebResponseMapper so an unusable #[WebQuery(factory:)] raises InvalidWebFactoryException instead of a generic Error. --- composer.json | 1 + src/MediaQueryWebModule.php | 3 +++ src/WebResponseMapper.php | 4 ++++ tests/Fake/FakeNonPublicProductFactory.php | 13 +++++++++++++ tests/WebResponseMapperTest.php | 7 +++++++ 5 files changed, 28 insertions(+) create mode 100644 tests/Fake/FakeNonPublicProductFactory.php diff --git a/composer.json b/composer.json index e4af30a..d9be8b2 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "guzzlehttp/guzzle": "^7.2", + "phpdocumentor/reflection-docblock": "^6.0", "psr/http-message": "^2.0", "ray/aop": "^2.18", "ray/di": "^2.18", diff --git a/src/MediaQueryWebModule.php b/src/MediaQueryWebModule.php index 272dad6..3fd24ec 100644 --- a/src/MediaQueryWebModule.php +++ b/src/MediaQueryWebModule.php @@ -7,6 +7,8 @@ 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; @@ -41,6 +43,7 @@ public function configure(): void $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/WebResponseMapper.php b/src/WebResponseMapper.php index ef9bb58..00c7bf7 100644 --- a/src/WebResponseMapper.php +++ b/src/WebResponseMapper.php @@ -103,6 +103,10 @@ private function factoryConverter(string $factory): Closure } $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 */ 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 @@ +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 */ From d1eddbaba2e1d6fff394b44a2d6a1b1b587ffc83 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 12:39:27 +0900 Subject: [PATCH 08/10] Widen reflection-docblock constraint to ^5.3 || ^6.0 Pinning ^6.0 broke the --prefer-lowest build: the lowest ray/media-query (1.0.0) allows reflection-docblock 5.x, and lowest vimeo/psalm pulls felixfbecker/advanced-json-rpc, which requires reflection-docblock ^4.3.4 || ^5.0. Forcing ^6.0 made that set unresolvable. The union matches media-query's own historical constraints and keeps both the lowest and highest builds installable. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d9be8b2..5a8a1d4 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.2", "guzzlehttp/guzzle": "^7.2", - "phpdocumentor/reflection-docblock": "^6.0", + "phpdocumentor/reflection-docblock": "^5.3 || ^6.0", "psr/http-message": "^2.0", "ray/aop": "^2.18", "ray/di": "^2.18", From b853968d594cdf995771543b62255b72e9f15345 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 12:48:05 +0900 Subject: [PATCH 09/10] Cover the remaining response-mapping branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore 100% line coverage for the new BDR code: - WebResponseMapper::bind() — a missing key now exercises both the default-value fallback and the nullable-injects-null branch. - WebQueryInterceptor::isRow() — a genuine union return type (FakeProductEntity|FakeProductList) maps to a single object. --- tests/Fake/WebApi/FooProductInterface.php | 4 ++++ tests/WebQueryBdrTest.php | 10 ++++++++++ tests/WebResponseMapperTest.php | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/tests/Fake/WebApi/FooProductInterface.php b/tests/Fake/WebApi/FooProductInterface.php index 5701e87..984b270 100644 --- a/tests/Fake/WebApi/FooProductInterface.php +++ b/tests/Fake/WebApi/FooProductInterface.php @@ -34,6 +34,10 @@ public function listWithTax(string $status): array; #[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/WebQueryBdrTest.php b/tests/WebQueryBdrTest.php index ea864d9..f5b2242 100644 --- a/tests/WebQueryBdrTest.php +++ b/tests/WebQueryBdrTest.php @@ -55,6 +55,16 @@ public function testStaticFactoryBuildsEntity(): void $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}]'; diff --git a/tests/WebResponseMapperTest.php b/tests/WebResponseMapperTest.php index ffc9241..d32fdfa 100644 --- a/tests/WebResponseMapperTest.php +++ b/tests/WebResponseMapperTest.php @@ -73,6 +73,25 @@ public function testMissingRequiredKeyThrows(): void $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 { From 23493185551be15dcfbd671b2bb8b0d894f79c30 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Thu, 25 Jun 2026 13:33:29 +0900 Subject: [PATCH 10/10] Clarify isRow doc comment: union means nullable T|null --- src/WebQueryInterceptor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WebQueryInterceptor.php b/src/WebQueryInterceptor.php index 692b103..4fcf784 100644 --- a/src/WebQueryInterceptor.php +++ b/src/WebQueryInterceptor.php @@ -83,8 +83,8 @@ private function postFetchClass(ReflectionType|null $returnType): string|null } /** - * A single object is returned for `type: 'row'`, a union return type, or a - * named (non-array, non-PostFetch) class return type. Otherwise a list. + * 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 {