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/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 fe5b346..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 @@ -121,7 +121,7 @@ use ProgrammatorDev\Api\Builder\CacheBuilder; return $this ->endpoint() - ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) ->get('/live') ->collection(Event::class, key: 'data'); ``` @@ -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 ad87a64..4228800 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -238,13 +238,15 @@ SDK authors can configure endpoint-specific cache defaults on the endpoint build ```php return $this ->endpoint() - ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) ->get('/users') ->collection(User::class, key: 'data'); ``` Endpoint cache defaults are immutable and apply only to that request. They require API-level cache configuration because the global cache setup provides the PSR-6 pool. +`Endpoint::cache()` is deprecated since version 3.2.0. Use `Endpoint::withCache()` instead. + ## Resource Cache Overrides `withCache()` lets SDK users override cache behavior for one resource chain while keeping query, headers, body, and verbs inside `Endpoint`. @@ -258,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..acfad3b --- /dev/null +++ b/docs/07-resolver.md @@ -0,0 +1,288 @@ +# 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'], + resolver: $context->resolver(), + ); + } + + public function manager(): self + { + 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 ?string $previousUrl, + 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, + previousUrl: $data['previous'] ?? null, + resolver: $context->resolver(), + ); + } + + public function next(): ?self + { + if ($this->nextUrl === null) { + return null; + } + + 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); + } +} +``` + +## 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 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); + +$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. + +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. + +$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 91% rename from docs/09-cache.md rename to docs/10-cache.md index 51c2812..a3386dc 100644 --- a/docs/09-cache.md +++ b/docs/10-cache.md @@ -70,7 +70,7 @@ public function live(): FixtureCollection { return $this ->endpoint() - ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) ->get('/fixtures/live') ->envelope(FixtureCollection::class); } @@ -80,6 +80,8 @@ This is useful when the SDK author knows that one endpoint should behave differe Endpoint defaults do not mutate the API cache builder and do not affect later requests. +`Endpoint::cache()` is deprecated since version 3.2.0. Use `Endpoint::withCache()` instead. + ## Resource Overrides SDK users can override cache behavior for one resource chain with `withCache()`. The override wins over endpoint defaults, does not mutate the API cache builder, and does not affect later resource instances. @@ -109,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 8241eb0..fc442ee 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -3,15 +3,27 @@ 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 resolver(): ResolverInterface + { + if ($this->resolver === null) { + // Resolver-backed behavior is available only within 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..4cf3566 --- /dev/null +++ b/src/Contract/ResolverInterface.php @@ -0,0 +1,37 @@ + $class + * @return T + * @throws \Throwable + */ + public function entity(string $pathOrUrl, string $class, ?string $key = null): EntityInterface; + + /** + * @template T of EntityInterface + * @param class-string $class + * @return T[] + * @throws \Throwable + */ + public function collection(string $pathOrUrl, string $class, ?string $key = null): array; + + /** + * @template T of EnvelopeInterface + * @param class-string $class + * @return T + * @throws \Throwable + */ + public function envelope(string $pathOrUrl, string $class): EnvelopeInterface; +} diff --git a/src/Endpoint.php b/src/Endpoint.php index 55a73a6..6d8b6f7 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -23,13 +23,23 @@ public function __construct( /** * @param callable(\ProgrammatorDev\Api\Builder\CacheBuilder): mixed $configure */ - public function cache(callable $configure): static + public function withCache(callable $configure): static { return $this->withPipelineOptions( $this->pipelineOptions->withDefault(PipelineOption::CACHE, $configure) ); } + /** + * @deprecated since 3.2.0. Use withCache(). + * + * @param callable(\ProgrammatorDev\Api\Builder\CacheBuilder): mixed $configure + */ + public function cache(callable $configure): static + { + return $this->withCache($configure); + } + /** * @throws \JsonException */ diff --git a/src/Http/Transport.php b/src/Http/Transport.php index 0445439..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), + 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(); @@ -222,14 +239,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 new file mode 100644 index 0000000..c025fd6 --- /dev/null +++ b/src/Resolver/Resolver.php @@ -0,0 +1,83 @@ + */ + private array $responses = []; + + public function __construct( + private readonly Runtime $runtime + ) {} + + /** + * @throws \Throwable + */ + public function get(string $pathOrUrl): Response + { + $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: $requestOptions, + pipelineOptions: new PipelineOptions(), + resolver: $this + ); + } + + /** + * @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/src/Runtime.php b/src/Runtime.php index 56aaf6c..c25bfe5 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; @@ -55,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 @@ -66,9 +81,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..00ea8f0 --- /dev/null +++ b/tests/Fixture/LinkedUser.php @@ -0,0 +1,71 @@ +resolver(), + friendsUrl: $data['friends']['url'] ?? null, + managerUrl: $data['manager']['url'] ?? null + ); + } + + 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); + } + + /** + * @return User[] + */ + public function friends(): array + { + if ($this->friendsUrl === null) { + return []; + } + + 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/Fixture/UserPage.php b/tests/Fixture/UserPage.php new file mode 100644 index 0000000..84fef61 --- /dev/null +++ b/tests/Fixture/UserPage.php @@ -0,0 +1,63 @@ +data(); + + return new static( + users: $response->collection(User::class, key: 'data'), + nextUrl: $data['next'] ?? null, + previousUrl: $data['previous'] ?? 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); + } + + public function previous(): ?self + { + if ($this->previousUrl === null) { + return null; + } + + return $this->resolver->envelope($this->previousUrl, self::class); + } +} diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 584aa1d..dfbcc0e 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -57,17 +57,26 @@ public function createWithEndpointCache(array $data): Response { return $this ->endpoint() - ->cache(fn($cache) => $cache->methods(['POST'])) + ->withCache(fn($cache) => $cache->methods(['POST'])) ->json($data) ->post('/users'); } public function createWithChainedEndpointCache(array $data): Response + { + return $this + ->endpoint() + ->withCache(fn($cache) => $cache->methods(['POST'])) + ->withCache(fn($cache) => $cache->methods(['GET'])) + ->json($data) + ->post('/users'); + } + + public function createWithDeprecatedEndpointCache(array $data): Response { return $this ->endpoint() ->cache(fn($cache) => $cache->methods(['POST'])) - ->cache(fn($cache) => $cache->methods(['GET'])) ->json($data) ->post('/users'); } @@ -105,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/CacheTest.php b/tests/Integration/CacheTest.php index 5d7aa3f..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"}')); @@ -44,6 +68,20 @@ public function testEndpointCanOverrideCacheConfiguration(): void $this->assertCount(1, $client->getRequests()); } + public function testDeprecatedEndpointCacheAliasStillConfiguresCache(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->methods(['GET']); + + $first = $api->users()->createWithDeprecatedEndpointCache(['name' => 'John']); + $second = $api->users()->createWithDeprecatedEndpointCache(['name' => 'John']); + + $this->assertSame(['id' => 1, 'name' => 'John'], $first->data()); + $this->assertSame(['id' => 1, 'name' => 'John'], $second->data()); + $this->assertCount(1, $client->getRequests()); + } + public function testResourceCacheOverrideWinsOverEndpointCacheDefault(): void { $client = $this->mockClient( diff --git a/tests/Integration/ResolverTest.php b/tests/Integration/ResolverTest.php new file mode 100644 index 0000000..33cb0e2 --- /dev/null +++ b/tests/Integration/ResolverTest.php @@ -0,0 +1,239 @@ +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 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 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 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"}')); + + $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 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"}}')); + $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"}')); + $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 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"}')); + $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"}}')); + $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..53843ff 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,43 @@ 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->assertSame($resolver, $context->resolver()); + } + + public function testContextThrowsWhenResolverIsUnavailable(): void + { + $context = new Context(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Response resolver is not available outside an API runtime request.'); + + $context->resolver(); + } }