From a18bacc4902d861efa70ee6617391217fddaf51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 16:25:18 +0100 Subject: [PATCH 01/11] feat(resolver): add memoized response resolution --- src/Context/Context.php | 20 ++++++- src/Contract/ResolverInterface.php | 31 +++++++++++ src/Resolver/Resolver.php | 69 +++++++++++++++++++++++ src/Runtime.php | 10 +++- tests/Fixture/LinkedUser.php | 46 ++++++++++++++++ tests/Fixture/UserPage.php | 52 ++++++++++++++++++ tests/Fixture/UserResource.php | 16 ++++++ tests/Integration/ApiTest.php | 14 +++++ tests/Integration/ResolverTest.php | 88 ++++++++++++++++++++++++++++++ tests/Unit/ContextTest.php | 46 ++++++++++++++++ 10 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 src/Contract/ResolverInterface.php create mode 100644 src/Resolver/Resolver.php create mode 100644 tests/Fixture/LinkedUser.php create mode 100644 tests/Fixture/UserPage.php create mode 100644 tests/Integration/ResolverTest.php diff --git a/src/Context/Context.php b/src/Context/Context.php index 8241eb0..e1273f3 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -3,15 +3,33 @@ namespace ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Config\Config; +use ProgrammatorDev\Api\Contract\ResolverInterface; class Context { public function __construct( - private readonly Config $config = new Config() + private readonly Config $config = new Config(), + private readonly ?ResolverInterface $resolver = null ) {} public function config(): Config { return $this->config; } + + public function hasResolver(): bool + { + return $this->resolver !== null; + } + + public function resolver(): ResolverInterface + { + if ($this->resolver === null) { + // Manually-created contexts can still exist for tests or standalone hydration, + // but link resolution requires an API runtime request. + throw new \RuntimeException('Response resolver is not available outside an API runtime request.'); + } + + return $this->resolver; + } } diff --git a/src/Contract/ResolverInterface.php b/src/Contract/ResolverInterface.php new file mode 100644 index 0000000..9f0aa86 --- /dev/null +++ b/src/Contract/ResolverInterface.php @@ -0,0 +1,31 @@ + $class + * @return T + */ + public function entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface; + + /** + * @template T of EntityInterface + * @param class-string $class + * @return T[] + */ + public function collection(string $pathOrUrl, string $class, ?string $key = null): array; + + /** + * @template T of EnvelopeInterface + * @param class-string $class + * @return T + */ + public function envelope(string $pathOrUrl, string $class): EnvelopeInterface; +} diff --git a/src/Resolver/Resolver.php b/src/Resolver/Resolver.php new file mode 100644 index 0000000..239103a --- /dev/null +++ b/src/Resolver/Resolver.php @@ -0,0 +1,69 @@ + */ + private array $responses = []; + + public function __construct( + private readonly Runtime $runtime + ) {} + + public function get(string $pathOrUrl): Response + { + // Link following can be called repeatedly by entities/envelopes in the same response graph. + // Memoize the SDK Response, not typed objects, + // so mapping remains caller-owned while duplicate HTTP requests are avoided. + return $this->responses[$pathOrUrl] ??= $this->runtime->send( + method: Method::GET, + path: $pathOrUrl, + pathParams: [], + requestOptions: (new RequestOptions())->withQueries($this->queryFromUrl($pathOrUrl)), + pipelineOptions: new PipelineOptions(), + resolver: $this + ); + } + + public function entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface + { + return $this->get($pathOrUrl)->entity($class, $key); + } + + public function collection(string $pathOrUrl, string $class, ?string $key = null): array + { + return $this->get($pathOrUrl)->collection($class, $key); + } + + public function envelope(string $pathOrUrl, string $class): EnvelopeInterface + { + return $this->get($pathOrUrl)->envelope($class); + } + + private function queryFromUrl(string $pathOrUrl): array + { + // Query values in a followed link must take precedence over API defaults because the + // API generated that link. For example, with defaults `page=1&locale=en`, resolving + // `/users?page=2` must request `/users?page=2&locale=en`, not page 1. Promoting the + // link query to request-local options gives it that precedence during the normal merge. + $query = parse_url($pathOrUrl, PHP_URL_QUERY); + + if ($query === null || $query === false || $query === '') { + return []; + } + + parse_str($query, $values); + + return $values; + } +} diff --git a/src/Runtime.php b/src/Runtime.php index 56aaf6c..d626242 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -6,9 +6,11 @@ use ProgrammatorDev\Api\Config\Config; use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Context\ErrorContext; +use ProgrammatorDev\Api\Contract\ResolverInterface; use ProgrammatorDev\Api\Http\Transport; use ProgrammatorDev\Api\Request\PipelineOptions; use ProgrammatorDev\Api\Request\RequestOptions; +use ProgrammatorDev\Api\Resolver\Resolver; use ProgrammatorDev\Api\Response\Response; use ProgrammatorDev\Api\Response\ResponseDecoder; use Psr\Http\Client\ClientExceptionInterface; @@ -66,9 +68,13 @@ public function send( string $path, array $pathParams, RequestOptions $requestOptions, - PipelineOptions $pipelineOptions + PipelineOptions $pipelineOptions, + ?ResolverInterface $resolver = null ): Response { - $context = new Context($this->config()); + // Followed links pass their resolver back in + // so one response graph shares memoized responses while top-level requests get a fresh resolver scope. + $resolver ??= new Resolver($this); + $context = new Context($this->config(), $resolver); $rawResponse = ($this->transport)()->send( method: $method, diff --git a/tests/Fixture/LinkedUser.php b/tests/Fixture/LinkedUser.php new file mode 100644 index 0000000..4be7859 --- /dev/null +++ b/tests/Fixture/LinkedUser.php @@ -0,0 +1,46 @@ +resolver() + ); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function friend(): User + { + return $this->resolver->entity($this->friendUrl, User::class); + } +} diff --git a/tests/Fixture/UserPage.php b/tests/Fixture/UserPage.php new file mode 100644 index 0000000..13e2d0e --- /dev/null +++ b/tests/Fixture/UserPage.php @@ -0,0 +1,52 @@ +data(); + + return new static( + users: $response->collection(User::class, key: 'data'), + nextUrl: $data['next'] ?? null, + resolver: $context->resolver() + ); + } + + /** + * @return User[] + */ + public function users(): array + { + return $this->users; + } + + public function next(): ?self + { + if ($this->nextUrl === null) { + return null; + } + + return $this->resolver->envelope($this->nextUrl, self::class); + } +} diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index a64edf0..dfbcc0e 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -114,6 +114,22 @@ public function findEnvelope(int|string $id): UserEnvelope ->envelope(UserEnvelope::class); } + public function findLinked(int|string $id): LinkedUser + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(LinkedUser::class); + } + + public function page(): UserPage + { + return $this + ->endpoint() + ->get('/users') + ->envelope(UserPage::class); + } + public function findWithEndpointLocale(int|string $id, string $locale): User { return $this diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 8a86b87..74c6d88 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -90,6 +90,20 @@ public function testApiCanSendRequestWithDefaultQuery(): void $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $client->getLastRequest()->getUri()); } + public function testRequestQueryTakesPrecedenceOverUrlQueryAndDefaults(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + + (new FakeApi($client)) + ->withDefaultQuery('locale', 'en') + ->send(Method::GET, '/users?locale=pt&page=2', query: [ + 'page' => 1, + 'units' => 'metric', + ]); + + $this->assertSame('https://api.example.com/users?locale=en&page=1&units=metric', (string) $client->getLastRequest()->getUri()); + } + public function testApiCanUseConfigValuesAsDefaultQueries(): void { $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php new file mode 100644 index 0000000..b67e699 --- /dev/null +++ b/tests/Integration/ResolverTest.php @@ -0,0 +1,88 @@ +client = new Client(); + $this->api = new FakeApi($this->client); + } + + public function testEntityCanResolveLinkedResourceOnDemand(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + + $user = $this->api->users()->findLinked(1); + + $this->assertSame('John', $user->getName()); + $this->assertCount(1, $this->client->getRequests()); + + $friend = $user->friend(); + + $this->assertSame('Jane', $friend->getName()); + $this->assertSame('https://api.example.com/users/2?locale=en', (string) $this->client->getLastRequest()->getUri()); + $this->assertCount(2, $this->client->getRequests()); + } + + public function testResolverMemoizesResponsesWithinTheSameContext(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + + $user = $this->api->users()->findLinked(1); + + $first = $user->friend(); + $second = $user->friend(); + + $this->assertNotSame($first, $second); + $this->assertSame('Jane', $second->getName()); + $this->assertCount(2, $this->client->getRequests()); + } + + public function testEnvelopeCanResolveNextPageOnDemand(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"}],"next":"/users?page=2"}')); + $this->client->addResponse(new Response(body: '{"data":[{"id":2,"name":"Jane"}],"next":null}')); + $this->api->withDefaultQuery('page', 1); + + $page = $this->api->users()->page(); + + $this->assertSame('John', $page->users()[0]->getName()); + $this->assertCount(1, $this->client->getRequests()); + + $next = $page->next(); + + $this->assertSame('Jane', $next?->users()[0]->getName()); + $this->assertSame('https://api.example.com/users?page=2&locale=en', (string) $this->client->getLastRequest()->getUri()); + $this->assertCount(2, $this->client->getRequests()); + } + + public function testResolverUsesScopedResourceConfig(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + + $user = $this->api + ->users() + ->withConfig(['timezone' => 'Europe/Lisbon']) + ->findLinked(1); + + $friend = $user->friend(); + + $this->assertSame('Europe/Lisbon', $friend->getTimezone()); + } +} diff --git a/tests/Unit/ContextTest.php b/tests/Unit/ContextTest.php index 94ebbb4..ba49000 100644 --- a/tests/Unit/ContextTest.php +++ b/tests/Unit/ContextTest.php @@ -4,6 +4,10 @@ use ProgrammatorDev\Api\Config\Config; use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; +use ProgrammatorDev\Api\Contract\ResolverInterface; +use ProgrammatorDev\Api\Response\Response; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; class ContextTest extends AbstractTestCase @@ -23,4 +27,46 @@ public function testContextReturnsProvidedConfig(): void $this->assertSame($config, $context->config()); $this->assertSame('UTC', $context->config()->get('timezone')); } + + public function testContextReturnsProvidedResolver(): void + { + $resolver = new class implements ResolverInterface { + public function get(string $pathOrUrl): Response + { + throw new \RuntimeException('Not used.'); + } + + public function entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface + { + throw new \RuntimeException('Not used.'); + } + + public function collection(string $pathOrUrl, string $class, ?string $key = null): array + { + throw new \RuntimeException('Not used.'); + } + + public function envelope(string $pathOrUrl, string $class): EnvelopeInterface + { + throw new \RuntimeException('Not used.'); + } + }; + + $context = new Context(resolver: $resolver); + + $this->assertTrue($context->hasResolver()); + $this->assertSame($resolver, $context->resolver()); + } + + public function testContextThrowsWhenResolverIsUnavailable(): void + { + $context = new Context(); + + $this->assertFalse($context->hasResolver()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Response resolver is not available outside an API runtime request.'); + + $context->resolver(); + } } From 7f8ecbc9416fd280f66f108d3cc72e7835760569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 16:40:49 +0100 Subject: [PATCH 02/11] fix(resolver): preserve linked URL queries --- src/Http/Transport.php | 16 +++++++++++++--- src/Request/RequestOptions.php | 27 +++++++++++++++++++++++---- src/Resolver/Resolver.php | 24 ++++-------------------- tests/Integration/ResolverTest.php | 28 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/Http/Transport.php b/src/Http/Transport.php index 0445439..e2d70c8 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -84,7 +84,7 @@ public function send( $request = $this->createRequest( method: $method, - url: $this->buildUrl($path, $query), + url: $this->buildUrl($path, $query, $options->shouldPreserveUrlQuery()), headers: $headers, body: $options->getBody() ); @@ -222,14 +222,24 @@ private function normalizeBackedEnums(mixed $value, bool $stringify = false): mi ); } - private function buildUrl(string $path, array $query = []): string + private function buildUrl(string $path, array $query = [], bool $preserveUrlQuery = false): string { $query = array_filter($query, static fn(mixed $value): bool => $value !== null); $appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = UrlHelper::join($this->baseUrl, $path); - return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); + // A preserved URL query is authoritative. For example, with defaults + // `page=1&locale=en`, requesting `/users?page=2` must produce + // `/users?page=2&locale=en`. Skipping duplicate defaults also retains repeated values + // such as `tag=a&tag=b` and keys such as `filter.name` without reparsing the URL. + return append_query_string( + $url, + $appendQuery, + $preserveUrlQuery + ? APPEND_QUERY_STRING_SKIP_DUPLICATE + : APPEND_QUERY_STRING_REPLACE_DUPLICATE + ); } private function createRequest( diff --git a/src/Request/RequestOptions.php b/src/Request/RequestOptions.php index 12bbccb..1fa64e5 100644 --- a/src/Request/RequestOptions.php +++ b/src/Request/RequestOptions.php @@ -9,7 +9,8 @@ class RequestOptions public function __construct( private readonly array $query = [], private readonly array $headers = [], - private readonly string|StreamInterface|null $body = null + private readonly string|StreamInterface|null $body = null, + private readonly bool $preserveUrlQuery = false ) {} public function getQuery(): array @@ -27,6 +28,11 @@ public function getBody(): string|StreamInterface|null return $this->body; } + public function shouldPreserveUrlQuery(): bool + { + return $this->preserveUrlQuery; + } + public function withQuery(string $name, mixed $value): self { return $this->withQueries([$name => $value]); @@ -37,7 +43,8 @@ public function withQueries(array $query): self return new self( query: array_merge($this->query, $this->filterNullValues($query)), headers: $this->headers, - body: $this->body + body: $this->body, + preserveUrlQuery: $this->preserveUrlQuery ); } @@ -51,7 +58,8 @@ public function withHeaders(array $headers): self return new self( query: $this->query, headers: array_merge($this->headers, $headers), - body: $this->body + body: $this->body, + preserveUrlQuery: $this->preserveUrlQuery ); } @@ -60,7 +68,18 @@ public function withBody(string|StreamInterface|null $body): self return new self( query: $this->query, headers: $this->headers, - body: $body + body: $body, + preserveUrlQuery: $this->preserveUrlQuery + ); + } + + public function withPreservedUrlQuery(): self + { + return new self( + query: $this->query, + headers: $this->headers, + body: $this->body, + preserveUrlQuery: true ); } diff --git a/src/Resolver/Resolver.php b/src/Resolver/Resolver.php index 239103a..e022c02 100644 --- a/src/Resolver/Resolver.php +++ b/src/Resolver/Resolver.php @@ -22,14 +22,14 @@ public function __construct( public function get(string $pathOrUrl): Response { - // Link following can be called repeatedly by entities/envelopes in the same response graph. - // Memoize the SDK Response, not typed objects, - // so mapping remains caller-owned while duplicate HTTP requests are avoided. + // Entities and envelopes may follow the same link repeatedly within one response graph. + // Memoize the SDK Response rather than mapped objects so mapping remains caller-owned + // while duplicate HTTP requests are avoided. return $this->responses[$pathOrUrl] ??= $this->runtime->send( method: Method::GET, path: $pathOrUrl, pathParams: [], - requestOptions: (new RequestOptions())->withQueries($this->queryFromUrl($pathOrUrl)), + requestOptions: (new RequestOptions())->withPreservedUrlQuery(), pipelineOptions: new PipelineOptions(), resolver: $this ); @@ -50,20 +50,4 @@ public function envelope(string $pathOrUrl, string $class): EnvelopeInterface return $this->get($pathOrUrl)->envelope($class); } - private function queryFromUrl(string $pathOrUrl): array - { - // Query values in a followed link must take precedence over API defaults because the - // API generated that link. For example, with defaults `page=1&locale=en`, resolving - // `/users?page=2` must request `/users?page=2&locale=en`, not page 1. Promoting the - // link query to request-local options gives it that precedence during the normal merge. - $query = parse_url($pathOrUrl, PHP_URL_QUERY); - - if ($query === null || $query === false || $query === '') { - return []; - } - - parse_str($query, $values); - - return $values; - } } diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index b67e699..d090695 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -71,6 +71,34 @@ public function testEnvelopeCanResolveNextPageOnDemand(): void $this->assertCount(2, $this->client->getRequests()); } + public function testResolverPreservesRepeatedUrlQueryValues(): void + { + $this->client->addResponse(new Response(body: '{"data":[],"next":"/users?tag=a&tag=b"}')); + $this->client->addResponse(new Response(body: '{"data":[],"next":null}')); + $this->api->withDefaultQuery('tag', 'default'); + + $this->api->users()->page()->next(); + + $this->assertSame( + 'https://api.example.com/users?tag=a&tag=b&locale=en', + (string) $this->client->getLastRequest()->getUri() + ); + } + + public function testResolverPreservesDottedUrlQueryKeys(): void + { + $this->client->addResponse(new Response(body: '{"data":[],"next":"/users?filter.name=active"}')); + $this->client->addResponse(new Response(body: '{"data":[],"next":null}')); + $this->api->withDefaultQuery('filter.name', 'default'); + + $this->api->users()->page()->next(); + + $this->assertSame( + 'https://api.example.com/users?filter.name=active&locale=en', + (string) $this->client->getLastRequest()->getUri() + ); + } + public function testResolverUsesScopedResourceConfig(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); From c132e1c9aaadfb692798a2337f45fc2599f8e0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 16:47:22 +0100 Subject: [PATCH 03/11] test(resolver): cover absolute linked URLs --- tests/Integration/ResolverTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index d090695..03897bd 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -38,6 +38,22 @@ public function testEntityCanResolveLinkedResourceOnDemand(): void $this->assertCount(2, $this->client->getRequests()); } + public function testResolverPreservesAbsoluteLinkedUrl(): void + { + $this->client->addResponse(new Response( + body: '{"id":1,"name":"John","friend":{"url":"https://relationships.example.com/users/2"}}' + )); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + + $friend = $this->api->users()->findLinked(1)->friend(); + + $this->assertSame('Jane', $friend->getName()); + $this->assertSame( + 'https://relationships.example.com/users/2?locale=en', + (string) $this->client->getLastRequest()->getUri() + ); + } + public function testResolverMemoizesResponsesWithinTheSameContext(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); From 6e19390c9eb360f5f10e42a8ae35837108038eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 16:49:20 +0100 Subject: [PATCH 04/11] test(resolver): cover linked collections --- tests/Fixture/LinkedUser.php | 18 ++++++++++++++++-- tests/Integration/ResolverTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/Fixture/LinkedUser.php b/tests/Fixture/LinkedUser.php index 4be7859..e76d783 100644 --- a/tests/Fixture/LinkedUser.php +++ b/tests/Fixture/LinkedUser.php @@ -12,7 +12,8 @@ public function __construct( private readonly int $id, private readonly string $name, private readonly string $friendUrl, - private readonly ResolverInterface $resolver + private readonly ResolverInterface $resolver, + private readonly ?string $friendsUrl = null ) {} public static function fromArray(array $data, ?Context $context = null): static @@ -25,7 +26,8 @@ public static function fromArray(array $data, ?Context $context = null): static id: $data['id'], name: $data['name'], friendUrl: $data['friend']['url'], - resolver: $context->resolver() + resolver: $context->resolver(), + friendsUrl: $data['friends']['url'] ?? null ); } @@ -43,4 +45,16 @@ public function friend(): User { return $this->resolver->entity($this->friendUrl, User::class); } + + /** + * @return User[] + */ + public function friends(): array + { + if ($this->friendsUrl === null) { + return []; + } + + return $this->resolver->collection($this->friendsUrl, User::class, key: 'data'); + } } diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index 03897bd..69ea155 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -5,6 +5,7 @@ use Http\Mock\Client; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\FakeApi; +use ProgrammatorDev\Api\Test\Fixture\User; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; class ResolverTest extends AbstractTestCase @@ -54,6 +55,33 @@ public function testResolverPreservesAbsoluteLinkedUrl(): void ); } + public function testResolverMapsLinkedCollectionOnDemand(): void + { + $this->client->addResponse(new Response( + body: '{"id":1,"name":"John","friend":{"url":"/users/2"},"friends":{"url":"/users/related"}}' + )); + $this->client->addResponse(new Response( + body: '{"data":[{"id":2,"name":"Jane"},{"id":3,"name":"Jack"}]}' + )); + + $user = $this->api->users()->findLinked(1); + + $this->assertCount(1, $this->client->getRequests()); + + $friends = $user->friends(); + + $this->assertContainsOnlyInstancesOf(User::class, $friends); + $this->assertSame(['Jane', 'Jack'], array_map( + static fn(User $friend): string => $friend->getName(), + $friends + )); + $this->assertSame( + 'https://api.example.com/users/related?locale=en', + (string) $this->client->getLastRequest()->getUri() + ); + $this->assertCount(2, $this->client->getRequests()); + } + public function testResolverMemoizesResponsesWithinTheSameContext(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); From 55121f3faf83f2bc84ea2b03f1bb4d8a27822533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 16:54:41 +0100 Subject: [PATCH 05/11] test(resolver): cover isolated memoization --- src/Contract/ResolverInterface.php | 6 ++++++ src/Resolver/Resolver.php | 22 +++++++++++++++++++++- tests/Integration/ResolverTest.php | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Contract/ResolverInterface.php b/src/Contract/ResolverInterface.php index 9f0aa86..4cf3566 100644 --- a/src/Contract/ResolverInterface.php +++ b/src/Contract/ResolverInterface.php @@ -6,12 +6,16 @@ interface ResolverInterface { + /** + * @throws \Throwable + */ public function get(string $pathOrUrl): Response; /** * @template T of EntityInterface * @param class-string $class * @return T + * @throws \Throwable */ public function entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface; @@ -19,6 +23,7 @@ public function entity(string $pathOrUrl, string $class, ?string $key = null): E * @template T of EntityInterface * @param class-string $class * @return T[] + * @throws \Throwable */ public function collection(string $pathOrUrl, string $class, ?string $key = null): array; @@ -26,6 +31,7 @@ public function collection(string $pathOrUrl, string $class, ?string $key = null * @template T of EnvelopeInterface * @param class-string $class * @return T + * @throws \Throwable */ public function envelope(string $pathOrUrl, string $class): EnvelopeInterface; } diff --git a/src/Resolver/Resolver.php b/src/Resolver/Resolver.php index e022c02..99e6d7c 100644 --- a/src/Resolver/Resolver.php +++ b/src/Resolver/Resolver.php @@ -20,6 +20,9 @@ public function __construct( private readonly Runtime $runtime ) {} + /** + * @throws \Throwable + */ public function get(string $pathOrUrl): Response { // Entities and envelopes may follow the same link repeatedly within one response graph. @@ -35,19 +38,36 @@ public function get(string $pathOrUrl): Response ); } + /** + * @template T of EntityInterface + * @param class-string $class + * @return T + * @throws \Throwable + */ public function entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface { return $this->get($pathOrUrl)->entity($class, $key); } + /** + * @template T of EntityInterface + * @param class-string $class + * @return T[] + * @throws \Throwable + */ public function collection(string $pathOrUrl, string $class, ?string $key = null): array { return $this->get($pathOrUrl)->collection($class, $key); } + /** + * @template T of EnvelopeInterface + * @param class-string $class + * @return T + * @throws \Throwable + */ public function envelope(string $pathOrUrl, string $class): EnvelopeInterface { return $this->get($pathOrUrl)->envelope($class); } - } diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index 69ea155..23607cf 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -97,6 +97,21 @@ public function testResolverMemoizesResponsesWithinTheSameContext(): void $this->assertCount(2, $this->client->getRequests()); } + public function testResolverMemoizationDoesNotLeakAcrossResponseGraphs(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Janet"}')); + + $first = $this->api->users()->findLinked(1)->friend(); + $second = $this->api->users()->findLinked(1)->friend(); + + $this->assertSame('Jane', $first->getName()); + $this->assertSame('Janet', $second->getName()); + $this->assertCount(4, $this->client->getRequests()); + } + public function testEnvelopeCanResolveNextPageOnDemand(): void { $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"}],"next":"/users?page=2"}')); From 8ba2fd1ef6d4dc67e016e469e7a25f6598481d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 16:58:07 +0100 Subject: [PATCH 06/11] test(cache): cover resolver requests --- tests/Integration/CacheTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php index 6b438da..947eee5 100644 --- a/tests/Integration/CacheTest.php +++ b/tests/Integration/CacheTest.php @@ -30,6 +30,30 @@ public function testSdkUserCanConfigureCache(): void $this->assertCount(1, $client->getRequests()); } + public function testApiCacheAppliesToResolverRequestsAcrossResponseGraphs(): void + { + $client = $this->mockClient( + new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}' + ), + new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":2,"name":"Jane"}' + ) + ); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->defaultTtl(60); + + $first = $api->users()->findLinked(1)->friend(); + $second = $api->users()->findLinked(1)->friend(); + + $this->assertNotSame($first, $second); + $this->assertSame('Jane', $first->getName()); + $this->assertSame('Jane', $second->getName()); + $this->assertCount(2, $client->getRequests()); + } + public function testEndpointCanOverrideCacheConfiguration(): void { $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); From e0b7bbdef79578ac2ed2639795807c4ed5e1a532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 17:23:05 +0100 Subject: [PATCH 07/11] docs(resolver): document linked response resolution --- README.md | 13 +- UPGRADE-3.0.md | 6 +- docs/00-index.md | 13 +- docs/03-api.md | 12 +- docs/04-resource-authoring.md | 4 + docs/05-resources.md | 2 +- docs/06-responses.md | 17 +- docs/07-resolver.md | 269 ++++++++++++++++++ ...authentication.md => 08-authentication.md} | 4 +- docs/{08-http-client.md => 09-http-client.md} | 6 +- docs/{09-cache.md => 10-cache.md} | 6 +- docs/{10-logging.md => 11-logging.md} | 6 +- docs/{11-plugins.md => 12-plugins.md} | 6 +- docs/{12-hooks.md => 13-hooks.md} | 2 +- src/Context/Context.php | 8 +- tests/Unit/ContextTest.php | 3 - 16 files changed, 328 insertions(+), 49 deletions(-) create mode 100644 docs/07-resolver.md rename docs/{07-authentication.md => 08-authentication.md} (96%) rename docs/{08-http-client.md => 09-http-client.md} (93%) rename docs/{09-cache.md => 10-cache.md} (96%) rename docs/{10-logging.md => 11-logging.md} (90%) rename docs/{11-plugins.md => 12-plugins.md} (95%) rename docs/{12-hooks.md => 13-hooks.md} (98%) diff --git a/README.md b/README.md index 881a76e..a14defb 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,13 @@ SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementat - [Resource Authoring](docs/04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific resource chains. - [Resources](docs/05-resources.md): resource classes and endpoint request helpers. - [Responses](docs/06-responses.md): decoded data, raw responses, entities, collections, envelopes, and context. -- [Authentication](docs/07-authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. -- [HTTP Client](docs/08-http-client.md): configure PSR-18 clients and PSR-17 factories. -- [Cache](docs/09-cache.md): configure PSR-6 HTTP response caching. -- [Logging](docs/10-logging.md): configure PSR-3 logging and HTTP/cache log output. -- [Plugins](docs/11-plugins.md): configure HTTPlug middleware and priority ordering. -- [Hooks](docs/12-hooks.md): run SDK-author callbacks around requests and responses. +- [Resolver](docs/07-resolver.md): follow linked entities, collections, and pagination through the configured SDK runtime. +- [Authentication](docs/08-authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [HTTP Client](docs/09-http-client.md): configure PSR-18 clients and PSR-17 factories. +- [Cache](docs/10-cache.md): configure PSR-6 HTTP response caching. +- [Logging](docs/11-logging.md): configure PSR-3 logging and HTTP/cache log output. +- [Plugins](docs/12-plugins.md): configure HTTPlug middleware and priority ordering. +- [Hooks](docs/13-hooks.md): run SDK-author callbacks around requests and responses. ## Upgrading diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md index 73151af..7b837f1 100644 --- a/UPGRADE-3.0.md +++ b/UPGRADE-3.0.md @@ -103,7 +103,7 @@ $this->auth()->bearer($token); Use `chain()` only when an API requires multiple authentication rules on the same request. -See [Authentication](docs/07-authentication.md), [HTTP Client](docs/08-http-client.md), [Cache](docs/09-cache.md), [Logging](docs/10-logging.md), [Plugins](docs/11-plugins.md), and [Hooks](docs/12-hooks.md) for details. +See [Authentication](docs/08-authentication.md), [HTTP Client](docs/09-http-client.md), [Cache](docs/10-cache.md), [Logging](docs/11-logging.md), [Plugins](docs/12-plugins.md), and [Hooks](docs/13-hooks.md) for details. ## Defaults And Endpoint Overrides @@ -143,7 +143,7 @@ API cache config < endpoint cache defaults < resource withCache override The base package provides the generic override mechanism. API-specific fluent helpers, such as `withIncludes()` or `withStatus()`, should live in the concrete SDK. -See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authoring.md#api-specific-resource-chains), [Resources: Resource Cache Overrides](docs/05-resources.md#resource-cache-overrides), [Cache: Endpoint Defaults](docs/09-cache.md#endpoint-defaults), and [Cache: Resource Overrides](docs/09-cache.md#resource-overrides) for details. +See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authoring.md#api-specific-resource-chains), [Resources: Resource Cache Overrides](docs/05-resources.md#resource-cache-overrides), [Cache: Endpoint Defaults](docs/10-cache.md#endpoint-defaults), and [Cache: Resource Overrides](docs/10-cache.md#resource-overrides) for details. ## Setup Is The Escape Hatch @@ -177,7 +177,7 @@ The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. Whe SDK authors may still require or suggest concrete implementations when they want control over the default HTTP stack. -See [HTTP Client: SDK Author Defaults](docs/08-http-client.md#sdk-author-defaults) and [HTTP Client: SDK User Overrides](docs/08-http-client.md#sdk-user-overrides) for details. +See [HTTP Client: SDK Author Defaults](docs/09-http-client.md#sdk-author-defaults) and [HTTP Client: SDK User Overrides](docs/09-http-client.md#sdk-user-overrides) for details. ## API-Specific Behavior Belongs In SDKs diff --git a/docs/00-index.md b/docs/00-index.md index 33374c7..af0c851 100644 --- a/docs/00-index.md +++ b/docs/00-index.md @@ -35,12 +35,13 @@ SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementat - [Resource Authoring](04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific resource chains. - [Resources](05-resources.md): resource classes and endpoint request helpers. - [Responses](06-responses.md): decoded data, raw responses, entities, collections, envelopes, and context. -- [Authentication](07-authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. -- [HTTP Client](08-http-client.md): configure PSR-18 clients and PSR-17 factories. -- [Cache](09-cache.md): configure PSR-6 HTTP response caching. -- [Logging](10-logging.md): configure PSR-3 logging and HTTP/cache log output. -- [Plugins](11-plugins.md): configure HTTPlug middleware and priority ordering. -- [Hooks](12-hooks.md): run SDK-author callbacks around requests and responses. +- [Resolver](07-resolver.md): follow linked entities, collections, and pagination through the configured SDK runtime. +- [Authentication](08-authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [HTTP Client](09-http-client.md): configure PSR-18 clients and PSR-17 factories. +- [Cache](10-cache.md): configure PSR-6 HTTP response caching. +- [Logging](11-logging.md): configure PSR-3 logging and HTTP/cache log output. +- [Plugins](12-plugins.md): configure HTTPlug middleware and priority ordering. +- [Hooks](13-hooks.md): run SDK-author callbacks around requests and responses. ## Upgrading diff --git a/docs/03-api.md b/docs/03-api.md index 4f12e40..9708550 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -208,7 +208,7 @@ Authentication is applied automatically to outgoing requests. Calling another auth helper replaces the previous authentication. Use `chain()` when multiple authentication rules are required. -See [Authentication](07-authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. +See [Authentication](08-authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. ### `hooks()` @@ -225,7 +225,7 @@ $this->hooks()->afterResponse($hook); Hooks are SDK-author extension points. They run around the raw HTTP request and response, before response decoding and error handling. -See [Hooks](12-hooks.md) for hook context objects, return values, and priority behavior. +See [Hooks](13-hooks.md) for hook context objects, return values, and priority behavior. ### `plugins()` @@ -241,7 +241,7 @@ $this->plugins()->add($plugin, priority: 16); Higher priority plugins run earlier. Same-priority plugins are preserved in insertion order. -See [Plugins](11-plugins.md) for internal plugin order and priority guidance. +See [Plugins](12-plugins.md) for internal plugin order and priority guidance. ### `cache()` @@ -258,7 +258,7 @@ $this ->methods(['GET', 'HEAD']); ``` -See [Cache](09-cache.md) for cache options and plugin order. +See [Cache](10-cache.md) for cache options and plugin order. ### `client()` @@ -281,7 +281,7 @@ $this ->streamFactory($streamFactory); ``` -See [HTTP Client](08-http-client.md) for client and factory configuration. +See [HTTP Client](09-http-client.md) for client and factory configuration. ### `logger()` @@ -297,7 +297,7 @@ $this ->formatter($formatter); ``` -See [Logging](10-logging.md) for logger formatting and cache logging. +See [Logging](11-logging.md) for logger formatting and cache logging. ## Response Handling diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 235f478..4978eaa 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -367,6 +367,10 @@ final class UserEnvelope implements EnvelopeInterface Keep context usage focused on hydration decisions. Entities should still be data/value objects by default and should not perform hidden network calls. +When an API exposes relationships or pagination as links, an SDK author can opt +into explicit request-backed methods through the context resolver. See +[Resolver](07-resolver.md). + ## Resource-Local Configuration > **Available since version 3.1.0.** diff --git a/docs/05-resources.md b/docs/05-resources.md index 341b05b..4228800 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -260,7 +260,7 @@ $users = $api This override is immutable and applies only to the chained resource instance. It requires API-level cache configuration because the global cache setup provides the PSR-6 pool. -See [Cache](09-cache.md) for endpoint cache defaults, merge order, and the API-level cache requirement. +See [Cache](10-cache.md) for endpoint cache defaults, merge order, and the API-level cache requirement. ## Navigation diff --git a/docs/06-responses.md b/docs/06-responses.md index 99ea090..ae62ffe 100644 --- a/docs/06-responses.md +++ b/docs/06-responses.md @@ -107,7 +107,7 @@ public static function fromResponse(Response $response, ?Context $context = null ## `Context` -`Context` carries SDK config into response mapping. +`Context` carries SDK config and response resolution into response mapping. SDK users do not fetch context from `Response`. The package passes context into entity and envelope hydration methods: @@ -133,6 +133,19 @@ this returns the effective API configuration plus its resource-local overrides. The same effective configuration is available to hooks and error handlers for that request. See [Resource-Local Configuration](04-resource-authoring.md#resource-local-configuration). +### `resolver()` + +```php +resolver(): ResolverInterface +``` + +Returns the response-graph resolver provided by the API runtime. It can follow +linked entities, collections, and pagination through the configured SDK runtime. +Calling it outside an API runtime request throws `RuntimeException`. + +See [Resolver](07-resolver.md) for linked-resource authoring, request behavior, +and memoization scope. + ## `ErrorContext` `ErrorContext` is passed to configured error handlers. @@ -173,4 +186,4 @@ It exposes: ## Navigation - Previous: [Resources](05-resources.md) -- Next: [Authentication](07-authentication.md) +- Next: [Resolver](07-resolver.md) diff --git a/docs/07-resolver.md b/docs/07-resolver.md new file mode 100644 index 0000000..58607ef --- /dev/null +++ b/docs/07-resolver.md @@ -0,0 +1,269 @@ +# Resolver + +> **Available since version 3.2.0.** + +The resolver lets entities and envelopes follow API-provided links through the +same configured SDK runtime. SDK authors opt into this behavior explicitly when +a relationship or pagination method calls the resolver. + +The package does not inspect entity properties, create proxies, or perform a +request during hydration. A linked request is made only when the SDK method that +uses the resolver is called. + +## Access From Context + +API runtime responses provide a resolver through hydration context: + +```php +$resolver = $context->resolver(); +``` + +Resolver-backed entities and envelopes use the context provided by the API +runtime request. + +## Linked Entities + +Store the resolver and the relationship URL during hydration, then resolve the +relationship from a purpose-built SDK method: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; +use ProgrammatorDev\Api\Contract\ResolverInterface; + +final class User implements EntityInterface +{ + public function __construct( + private readonly int $id, + private readonly string $name, + private readonly string $email, + private readonly ?string $managerUrl, + private readonly ResolverInterface $resolver, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self( + id: $data['id'], + name: $data['name'], + email: $data['email'], + managerUrl: $data['manager']['url'] ?? null, + resolver: $context->resolver(), + ); + } + + public function manager(): ?self + { + if ($this->managerUrl === null) { + return null; + } + + return $this->resolver->entity($this->managerUrl, self::class); + } + + public function name(): string + { + return $this->name; + } + + public function email(): string + { + return $this->email; + } +} +``` + +Calling `manager()` performs the linked request the first time that URL is +resolved in the current response graph. Hydrating the original `User` does not. + +## Linked Collections + +Use `collection()` when a relationship URL returns a list. Given a +`$colleaguesUrl` captured from the payload during `fromArray()`: + +```php +/** + * @return User[] + */ +public function colleagues(): array +{ + return $this->resolver->collection( + $this->colleaguesUrl, + User::class, + key: 'data', + ); +} +``` + +The resolver returns a plain array and uses the normal entity hydration path for +every item. + +## Pagination + +Envelopes can use the same resolver for next and previous links: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; +use ProgrammatorDev\Api\Contract\ResolverInterface; +use ProgrammatorDev\Api\Response\Response; + +final class UserPage implements EnvelopeInterface +{ + /** + * @param User[] $users + */ + public function __construct( + private readonly array $users, + private readonly ?string $nextUrl, + private readonly ResolverInterface $resolver, + ) {} + + public static function fromResponse(Response $response, ?Context $context = null): static + { + $data = $response->data(); + + return new self( + users: $response->collection(User::class, key: 'data'), + nextUrl: $data['next'] ?? null, + resolver: $context->resolver(), + ); + } + + public function next(): ?self + { + if ($this->nextUrl === null) { + return null; + } + + return $this->resolver->envelope($this->nextUrl, self::class); + } +} +``` + +## Resolver Methods + +### `get()` + +```php +get(string $pathOrUrl): Response +``` + +Performs a `GET` request and returns the SDK `Response` wrapper. + +### `entity()` + +```php +entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface +``` + +Resolves the URL and maps its response to an entity. + +### `collection()` + +```php +collection(string $pathOrUrl, string $class, ?string $key = null): array +``` + +Resolves the URL and maps its response to a plain array of entities. + +### `envelope()` + +```php +envelope(string $pathOrUrl, string $class): EnvelopeInterface +``` + +Resolves the URL and maps its response to an envelope. + +All resolver methods can propagate request, decoding, error-mapping, and +hydration exceptions. + +## Request Pipeline + +Resolver requests use the same runtime as the response that provided the +context. This includes: + +- Base URL resolution for relative links. +- API default query parameters and headers. +- Authentication, plugins, API-level cache, logging, and hooks. +- Response decoding and error mapping. +- API config and resource-local `withConfig()` values. + +Resolver requests start a new request-local pipeline scope. Cache modifiers +applied through an initiating resource or endpoint are not inherited; configure +API-level cache when linked requests should share HTTP cache behavior. + +Absolute links are requested as provided and still pass through configured +authentication and plugins. SDK authors should resolve only trusted API links +or use [conditional authentication](08-authentication.md#conditional-authentication) +when credentials must be limited by URL. + +## URL Query Precedence + +Query values supplied by an API link are authoritative. Missing API defaults are +appended without replacing or reparsing the link query. + +With defaults `page=1&locale=en`, resolving: + +```text +/users?page=2 +``` + +requests: + +```text +/users?page=2&locale=en +``` + +Repeated values such as `tag=a&tag=b` and keys such as `filter.name` are +preserved. + +## Memoization + +Each top-level response graph receives its own resolver. Within that graph, the +resolver memoizes the SDK `Response` by the exact path or URL passed to it. +Resolving the same link again avoids another HTTP request, while entity, +collection, and envelope mapping still creates new typed objects. + +```php +$user = $api->users()->find(1); + +$name = $user->manager()->name(); // Sends the manager request. +$email = $user->manager()->email(); // Reuses the response; no additional request. +``` + +Each `manager()` call maps a separate `User` object from the memoized response. +The second call does not send another HTTP request. Memoization does not turn +entities into shared mutable objects. + +Memoization does not cross independent top-level SDK requests. API-level HTTP +caching can reuse responses across those request graphs. + +```php +$firstUser = $api->users()->find(1); +$firstUser->manager(); // Requests the manager URL in the first graph. + +$secondUser = $api->users()->find(1); +$secondUser->manager(); // A new graph resolves the manager URL again. +``` + +With API-level HTTP caching configured, the second graph still uses its own +resolver but the HTTP cache may serve both responses without another network +request. + +The initial endpoint response is not registered in resolver memoization. For +example, following `next()` and then a `previous()` link back to the initial page +executes that initial request through the pipeline again. If API-level HTTP +caching is configured and the response is cacheable, the cache can prevent the +request from reaching the network. + +```php +$page1 = $api->users()->all(page: 1); // Initial endpoint request. +$page2 = $page1->next(); // Memoized by the resolver. +$page1Again = $page2?->previous(); // Runs page 1 through the pipeline again. +``` + +## Navigation + +- Previous: [Responses](06-responses.md) +- Next: [Authentication](08-authentication.md) diff --git a/docs/07-authentication.md b/docs/08-authentication.md similarity index 96% rename from docs/07-authentication.md rename to docs/08-authentication.md index 72f3102..147558f 100644 --- a/docs/07-authentication.md +++ b/docs/08-authentication.md @@ -94,5 +94,5 @@ Returning anything else throws an `UnexpectedValueException`. ## Navigation -- Previous: [Responses](06-responses.md) -- Next: [HTTP Client](08-http-client.md) +- Previous: [Resolver](07-resolver.md) +- Next: [HTTP Client](09-http-client.md) diff --git a/docs/08-http-client.md b/docs/09-http-client.md similarity index 93% rename from docs/08-http-client.md rename to docs/09-http-client.md index ae632ec..90e0139 100644 --- a/docs/08-http-client.md +++ b/docs/09-http-client.md @@ -68,9 +68,9 @@ HTTPlug plugins are not configured on the client builder. They are configured th $api->setup()->plugins()->add($plugin, priority: 25); ``` -See [Plugins](11-plugins.md) for plugin order and priority guidance. +See [Plugins](12-plugins.md) for plugin order and priority guidance. ## Navigation -- Previous: [Authentication](07-authentication.md) -- Next: [Cache](09-cache.md) +- Previous: [Authentication](08-authentication.md) +- Next: [Cache](10-cache.md) diff --git a/docs/09-cache.md b/docs/10-cache.md similarity index 96% rename from docs/09-cache.md rename to docs/10-cache.md index 12f7492..a3386dc 100644 --- a/docs/09-cache.md +++ b/docs/10-cache.md @@ -111,9 +111,9 @@ The cache plugin runs at priority `20`, after authentication and before the logg When logging is configured, cache hit/miss/write events are logged through the cache plugin listener. -See [Logging](10-logging.md) for cache log output. +See [Logging](11-logging.md) for cache log output. ## Navigation -- Previous: [HTTP Client](08-http-client.md) -- Next: [Logging](10-logging.md) +- Previous: [HTTP Client](09-http-client.md) +- Next: [Logging](11-logging.md) diff --git a/docs/10-logging.md b/docs/11-logging.md similarity index 90% rename from docs/10-logging.md rename to docs/11-logging.md index 56193a1..30c28e2 100644 --- a/docs/10-logging.md +++ b/docs/11-logging.md @@ -51,9 +51,9 @@ The logger plugin runs at priority `10`, after cache. That means the cache plugin can serve cached responses before the request reaches later plugins. Cache-specific logging is handled by the cache listener instead of relying only on the logger plugin. -See [Plugins](11-plugins.md) for the full internal plugin order. +See [Plugins](12-plugins.md) for the full internal plugin order. ## Navigation -- Previous: [Cache](09-cache.md) -- Next: [Plugins](11-plugins.md) +- Previous: [Cache](10-cache.md) +- Next: [Plugins](12-plugins.md) diff --git a/docs/11-plugins.md b/docs/12-plugins.md similarity index 95% rename from docs/11-plugins.md rename to docs/12-plugins.md index baeee38..0de50c2 100644 --- a/docs/11-plugins.md +++ b/docs/12-plugins.md @@ -4,7 +4,7 @@ Plugins are [HTTPlug](https://httplug.io/) middleware applied to outgoing reques See the [PHP-HTTP plugin documentation](https://docs.php-http.org/en/latest/plugins/index.html) for the underlying plugin system used here. -HTTP clients and PSR-17 factories are configured through [HTTP Client](08-http-client.md). Plugins are configured separately so middleware order remains explicit. +HTTP clients and PSR-17 factories are configured through [HTTP Client](09-http-client.md). Plugins are configured separately so middleware order remains explicit. SDK authors can configure plugins from the `Api` class: @@ -77,5 +77,5 @@ The request reaches `$first` before `$second`. ## Navigation -- Previous: [Logging](10-logging.md) -- Next: [Hooks](12-hooks.md) +- Previous: [Logging](11-logging.md) +- Next: [Hooks](13-hooks.md) diff --git a/docs/12-hooks.md b/docs/13-hooks.md similarity index 98% rename from docs/12-hooks.md rename to docs/13-hooks.md index b67eed8..b3d24e1 100644 --- a/docs/12-hooks.md +++ b/docs/13-hooks.md @@ -99,4 +99,4 @@ return Response ## Navigation -- Previous: [Plugins](11-plugins.md) +- Previous: [Plugins](12-plugins.md) diff --git a/src/Context/Context.php b/src/Context/Context.php index e1273f3..fc442ee 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -17,16 +17,10 @@ public function config(): Config return $this->config; } - public function hasResolver(): bool - { - return $this->resolver !== null; - } - public function resolver(): ResolverInterface { if ($this->resolver === null) { - // Manually-created contexts can still exist for tests or standalone hydration, - // but link resolution requires an API runtime request. + // Resolver-backed behavior is available only within an API runtime request. throw new \RuntimeException('Response resolver is not available outside an API runtime request.'); } diff --git a/tests/Unit/ContextTest.php b/tests/Unit/ContextTest.php index ba49000..53843ff 100644 --- a/tests/Unit/ContextTest.php +++ b/tests/Unit/ContextTest.php @@ -54,7 +54,6 @@ public function envelope(string $pathOrUrl, string $class): EnvelopeInterface $context = new Context(resolver: $resolver); - $this->assertTrue($context->hasResolver()); $this->assertSame($resolver, $context->resolver()); } @@ -62,8 +61,6 @@ public function testContextThrowsWhenResolverIsUnavailable(): void { $context = new Context(); - $this->assertFalse($context->hasResolver()); - $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Response resolver is not available outside an API runtime request.'); From bbe9c8b6979cd845c265b0be00f09a19c2549814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 18:02:15 +0100 Subject: [PATCH 08/11] fix(resolver): key memoization by resolved request URL --- docs/07-resolver.md | 18 +++++++++++--- src/Http/Transport.php | 33 +++++++++++++++++++------ src/Resolver/Resolver.php | 20 +++++++++++---- src/Runtime.php | 13 ++++++++++ tests/Fixture/LinkedUser.php | 15 ++++++++++-- tests/Integration/ResolverTest.php | 39 ++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 18 deletions(-) diff --git a/docs/07-resolver.md b/docs/07-resolver.md index 58607ef..77bca84 100644 --- a/docs/07-resolver.md +++ b/docs/07-resolver.md @@ -221,9 +221,14 @@ preserved. ## Memoization Each top-level response graph receives its own resolver. Within that graph, the -resolver memoizes the SDK `Response` by the exact path or URL passed to it. -Resolving the same link again avoids another HTTP request, while entity, -collection, and envelope mapping still creates new typed objects. +resolver memoizes the SDK `Response` by its transport-resolved URL, including +the base URL and effective default queries. Equivalent relative and absolute +links therefore share a memoized response. Resolving the same URL again avoids +another HTTP request, while entity, collection, and envelope mapping still +creates new typed objects. + +Memoization keys are created before request hooks and client plugins run. URL +changes made by those layers are not part of the resolver's request identity. ```php $user = $api->users()->find(1); @@ -239,6 +244,13 @@ entities into shared mutable objects. Memoization does not cross independent top-level SDK requests. API-level HTTP caching can reuse responses across those request graphs. +The resolver does not evict individual entries. Memoized responses remain in +memory while any response, entity, collection, or envelope from their shared +response graph keeps the resolver reachable. The complete memoization map is +released when that graph is no longer referenced. This is normally short-lived, +but traversing a very large number of paginated links can retain every followed +response until the traversal is released. + ```php $firstUser = $api->users()->find(1); $firstUser->manager(); // Requests the manager URL in the first graph. diff --git a/src/Http/Transport.php b/src/Http/Transport.php index e2d70c8..d3cc699 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -63,28 +63,21 @@ public function send( $options ??= new RequestOptions(); $pipelineOptions ??= new PipelineOptions(); $context ??= new Context(); - $path = $this->buildPath($path, $pathParams); - $query = $options->getQuery(); $headers = $options->getHeaders(); - if (!empty($this->defaultQueries)) { - $query = array_merge($this->defaultQueries, $query); - } - if (!empty($this->defaultHeaders)) { $headers = array_merge($this->defaultHeaders, $headers); } // Normalize after merging so API defaults and endpoint values // follow the same rules before request serialization. - $query = $this->normalizeBackedEnums($query); // PSR-7 requires header values to be strings, // including values from integer-backed enums. $headers = $this->normalizeBackedEnums($headers, stringify: true); $request = $this->createRequest( method: $method, - url: $this->buildUrl($path, $query, $options->shouldPreserveUrlQuery()), + url: $this->resolveUrl($path, $pathParams, $options), headers: $headers, body: $options->getBody() ); @@ -102,6 +95,30 @@ public function send( ); } + /** + * Build the URL exactly as send() will build it, before hooks and plugins + * can modify the PSR request. Resolver memoization uses this boundary so it + * does not need to duplicate base URL, path, or query-merging rules. + */ + public function resolveUrl( + string $path, + array $pathParams = [], + ?RequestOptions $options = null + ): string { + $options ??= new RequestOptions(); + $query = $options->getQuery(); + + if (!empty($this->defaultQueries)) { + $query = array_merge($this->defaultQueries, $query); + } + + return $this->buildUrl( + path: $this->buildPath($path, $pathParams), + query: $this->normalizeBackedEnums($query), + preserveUrlQuery: $options->shouldPreserveUrlQuery() + ); + } + private function buildPlugins(PipelineOptions $pipelineOptions): array { $plugins = new PluginBuilder(); diff --git a/src/Resolver/Resolver.php b/src/Resolver/Resolver.php index 99e6d7c..c025fd6 100644 --- a/src/Resolver/Resolver.php +++ b/src/Resolver/Resolver.php @@ -25,14 +25,24 @@ public function __construct( */ public function get(string $pathOrUrl): Response { - // Entities and envelopes may follow the same link repeatedly within one response graph. - // Memoize the SDK Response rather than mapped objects so mapping remains caller-owned - // while duplicate HTTP requests are avoided. - return $this->responses[$pathOrUrl] ??= $this->runtime->send( + $requestOptions = (new RequestOptions())->withPreservedUrlQuery(); + + // The supplied link is not the complete request identity: the runtime + // adds the method, base URL, and default queries to the memoization key. + $requestKey = $this->runtime->requestKey( + method: Method::GET, + path: $pathOrUrl, + pathParams: [], + requestOptions: $requestOptions + ); + + // Memoize the response rather than mapped objects so repeated links + // avoid HTTP requests while each mapping still creates a new object. + return $this->responses[$requestKey] ??= $this->runtime->send( method: Method::GET, path: $pathOrUrl, pathParams: [], - requestOptions: (new RequestOptions())->withPreservedUrlQuery(), + requestOptions: $requestOptions, pipelineOptions: new PipelineOptions(), resolver: $this ); diff --git a/src/Runtime.php b/src/Runtime.php index d626242..c25bfe5 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -57,6 +57,19 @@ public function withConfig(array $values): self ); } + public function requestKey( + string $method, + string $path, + array $pathParams, + RequestOptions $requestOptions + ): string { + // Keep memoization identity in the runtime instead of coupling resolvers + // to URL construction. The lazy transport also preserves later setup changes. + $url = ($this->transport)()->resolveUrl($path, $pathParams, $requestOptions); + + return sprintf('%s %s', strtoupper($method), $url); + } + /** * @throws ClientExceptionInterface * @throws \JsonException diff --git a/tests/Fixture/LinkedUser.php b/tests/Fixture/LinkedUser.php index e76d783..00ea8f0 100644 --- a/tests/Fixture/LinkedUser.php +++ b/tests/Fixture/LinkedUser.php @@ -13,7 +13,8 @@ public function __construct( private readonly string $name, private readonly string $friendUrl, private readonly ResolverInterface $resolver, - private readonly ?string $friendsUrl = null + private readonly ?string $friendsUrl = null, + private readonly ?string $managerUrl = null ) {} public static function fromArray(array $data, ?Context $context = null): static @@ -27,7 +28,8 @@ public static function fromArray(array $data, ?Context $context = null): static name: $data['name'], friendUrl: $data['friend']['url'], resolver: $context->resolver(), - friendsUrl: $data['friends']['url'] ?? null + friendsUrl: $data['friends']['url'] ?? null, + managerUrl: $data['manager']['url'] ?? null ); } @@ -57,4 +59,13 @@ public function friends(): array return $this->resolver->collection($this->friendsUrl, User::class, key: 'data'); } + + public function manager(): ?User + { + if ($this->managerUrl === null) { + return null; + } + + return $this->resolver->entity($this->managerUrl, User::class); + } } diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index 23607cf..7050e6b 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -97,6 +97,45 @@ public function testResolverMemoizesResponsesWithinTheSameContext(): void $this->assertCount(2, $this->client->getRequests()); } + public function testResolverMemoizesEquivalentRelativeAndAbsoluteUrls(): void + { + $this->client->addResponse(new Response(body: <<<'JSON' + { + "id": 1, + "name": "John", + "friend": {"url": "/users/2"}, + "manager": {"url": "https://api.example.com/users/2"} + } + JSON)); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + + $user = $this->api->users()->findLinked(1); + + $this->assertSame('Jane', $user->friend()->getName()); + $this->assertSame('Jane', $user->manager()?->getName()); + $this->assertCount(2, $this->client->getRequests()); + } + + public function testResolverIncludesCurrentDefaultQueriesInMemoizationKey(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Janet"}')); + + $user = $this->api->users()->findLinked(1); + + $this->assertSame('Jane', $user->friend()->getName()); + + $this->api->withDefaultQuery('locale', 'pt'); + + $this->assertSame('Janet', $user->friend()->getName()); + $this->assertSame( + 'https://api.example.com/users/2?locale=pt', + (string) $this->client->getLastRequest()->getUri() + ); + $this->assertCount(3, $this->client->getRequests()); + } + public function testResolverMemoizationDoesNotLeakAcrossResponseGraphs(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); From 1a452913c5ccabaedd70042ab7cbc0769320d998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 18:04:49 +0100 Subject: [PATCH 09/11] docs(resolver): complete pagination navigation example --- docs/07-resolver.md | 11 +++++++++++ tests/Fixture/UserPage.php | 11 +++++++++++ tests/Integration/ResolverTest.php | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/docs/07-resolver.md b/docs/07-resolver.md index 77bca84..ba8dc7a 100644 --- a/docs/07-resolver.md +++ b/docs/07-resolver.md @@ -116,6 +116,7 @@ final class UserPage implements EnvelopeInterface public function __construct( private readonly array $users, private readonly ?string $nextUrl, + private readonly ?string $previousUrl, private readonly ResolverInterface $resolver, ) {} @@ -126,6 +127,7 @@ final class UserPage implements EnvelopeInterface return new self( users: $response->collection(User::class, key: 'data'), nextUrl: $data['next'] ?? null, + previousUrl: $data['previous'] ?? null, resolver: $context->resolver(), ); } @@ -138,6 +140,15 @@ final class UserPage implements EnvelopeInterface return $this->resolver->envelope($this->nextUrl, self::class); } + + public function previous(): ?self + { + if ($this->previousUrl === null) { + return null; + } + + return $this->resolver->envelope($this->previousUrl, self::class); + } } ``` diff --git a/tests/Fixture/UserPage.php b/tests/Fixture/UserPage.php index 13e2d0e..84fef61 100644 --- a/tests/Fixture/UserPage.php +++ b/tests/Fixture/UserPage.php @@ -15,6 +15,7 @@ class UserPage implements EnvelopeInterface public function __construct( private readonly array $users, private readonly ?string $nextUrl, + private readonly ?string $previousUrl, private readonly ResolverInterface $resolver ) {} @@ -29,6 +30,7 @@ public static function fromResponse(Response $response, ?Context $context = null return new static( users: $response->collection(User::class, key: 'data'), nextUrl: $data['next'] ?? null, + previousUrl: $data['previous'] ?? null, resolver: $context->resolver() ); } @@ -49,4 +51,13 @@ public function next(): ?self return $this->resolver->envelope($this->nextUrl, self::class); } + + public function previous(): ?self + { + if ($this->previousUrl === null) { + return null; + } + + return $this->resolver->envelope($this->previousUrl, self::class); + } } diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index 7050e6b..a11cde0 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -169,6 +169,31 @@ public function testEnvelopeCanResolveNextPageOnDemand(): void $this->assertCount(2, $this->client->getRequests()); } + public function testReturningToInitialPageRunsItThroughThePipelineAgain(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"id":1,"name":"John"}],"next":"/users?page=2","previous":null}' + )); + $this->client->addResponse(new Response( + body: '{"data":[{"id":2,"name":"Jane"}],"next":null,"previous":"/users?page=1"}' + )); + $this->client->addResponse(new Response( + body: '{"data":[{"id":1,"name":"John again"}],"next":"/users?page=2","previous":null}' + )); + $this->api->withDefaultQuery('page', 1); + + $page1 = $this->api->users()->page(); + $page2 = $page1->next(); + $page1Again = $page2?->previous(); + + $this->assertSame('John again', $page1Again?->users()[0]->getName()); + $this->assertSame( + 'https://api.example.com/users?page=1&locale=en', + (string) $this->client->getLastRequest()->getUri() + ); + $this->assertCount(3, $this->client->getRequests()); + } + public function testResolverPreservesRepeatedUrlQueryValues(): void { $this->client->addResponse(new Response(body: '{"data":[],"next":"/users?tag=a&tag=b"}')); From ea5a47c86ed06cc5c1ff05fbbd9158f47a52a3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 18:07:59 +0100 Subject: [PATCH 10/11] docs(resolver): align manager relationship contract --- docs/07-resolver.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/07-resolver.md b/docs/07-resolver.md index ba8dc7a..acfad3b 100644 --- a/docs/07-resolver.md +++ b/docs/07-resolver.md @@ -37,7 +37,7 @@ final class User implements EntityInterface private readonly int $id, private readonly string $name, private readonly string $email, - private readonly ?string $managerUrl, + private readonly string $managerUrl, private readonly ResolverInterface $resolver, ) {} @@ -47,17 +47,13 @@ final class User implements EntityInterface id: $data['id'], name: $data['name'], email: $data['email'], - managerUrl: $data['manager']['url'] ?? null, + managerUrl: $data['manager']['url'], resolver: $context->resolver(), ); } - public function manager(): ?self + public function manager(): self { - if ($this->managerUrl === null) { - return null; - } - return $this->resolver->entity($this->managerUrl, self::class); } From 69d4aa416c03a140eabe05d06552513bf1b27791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 18:11:06 +0100 Subject: [PATCH 11/11] docs(resolver): document response graph architecture --- AGENTS.md | 30 +++++++++++++++++++++++++++--- tests/Integration/ResolverTest.php | 2 +- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index da92246..30acd90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,12 +24,16 @@ Keep the architecture centered on a small set of clear responsibilities: - `Setup`: the explicit SDK-user setup and hackability surface exposed through `Api::setup()`. - `Runtime`: the internal configured runtime used by resources for configuration - access and request execution. + access, request identity, and request execution. - `Resource`: an immutable endpoint group and the primary SDK-author workflow. - `Endpoint`: an immutable builder for request-local query, header, and body options. - `RequestOptions`: the request-local query, header, and body state carried by an endpoint. +- `Context`: the effective SDK config and response-graph capabilities passed + through response mapping and hydration. +- `Resolver`: the explicit, response-graph-scoped API for following linked + resources and pagination while reusing the originating runtime. - `Response`: the decoded/raw response wrapper and mapping surface. - `Entity`: the optional contract for typed response data objects. @@ -94,8 +98,20 @@ cache, hooks, decoding, and error handling. options merge, before serialization. - Authentication strategies must be explicit. Multiple strategies compose through `auth()->chain(...)` rather than relying on implicit precedence. -- Keep entities as response data/value objects by default. Do not introduce - hidden network calls, lazy loading, or transparent proxy behavior. +- Keep entities as response data/value objects by default. SDK authors may add + purpose-built relationship or pagination methods that explicitly defer a + request through the context resolver. Do not introduce transparent proxy + behavior, automatic property loading, or network calls from ordinary value + accessors. +- Resolver requests must reuse the originating runtime pipeline, including + current setup, config overrides, authentication, plugins, API-level cache, + hooks, decoding, and error handling. Request-local endpoint modifiers are not + inherited by followed links. +- Resolver memoization is scoped to one response graph. Memoize SDK responses by + request identity rather than sharing mapped entities or leaking state across + independent top-level requests. +- Treat query parameters already present in API-provided links as authoritative. + Apply missing API defaults without replacing or reparsing link queries. - Keep API-specific vocabulary in concrete SDK packages. Concepts such as includes, selects, filters, and pagination should build on generic resource primitives rather than enter the base package without broad applicability. @@ -128,6 +144,7 @@ Maintain support for the package's core capabilities: - Query and header defaults. - Base URL and path construction. - Response decoding and transformation. +- Explicit linked-response resolution and response-graph memoization. - Error handling. - Test utilities for SDK authors where they provide clear value. @@ -154,6 +171,8 @@ Documentation should explain: - How to create and configure a simple SDK. - How to author resources and request options. - How to map responses to entities, collections, and envelopes. +- How entities and envelopes can explicitly resolve linked resources and + pagination through their hydration context. - How to configure authentication, clients, factories, cache, logging, plugins, hooks, and errors. - How to create API-specific fluent helpers on top of generic primitives. @@ -186,6 +205,11 @@ For scoped or pipeline behavior, verify isolation and propagation explicitly: - Hooks, errors, responses, and hydration observe the same effective context. - Cache behavior does not leak request-local state. - Independent fluent modifiers compose correctly. +- Resolver requests use the same effective runtime pipeline as their originating + response. +- Resolver memoization is isolated by response graph and avoids duplicate + requests without sharing mapped objects. +- Linked URL query precedence and request identity match documented behavior. ## Downstream Validation diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php index a11cde0..33cb0e2 100644 --- a/tests/Integration/ResolverTest.php +++ b/tests/Integration/ResolverTest.php @@ -82,7 +82,7 @@ public function testResolverMapsLinkedCollectionOnDemand(): void $this->assertCount(2, $this->client->getRequests()); } - public function testResolverMemoizesResponsesWithinTheSameContext(): void + public function testResolverMemoizesResponsesWithinTheSameResponseGraph(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John","friend":{"url":"/users/2"}}')); $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}'));