diff --git a/docs/03-api.md b/docs/03-api.md index e97487e..c3ba603 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -75,6 +75,10 @@ $api->config(['timezone' => 'UTC']); $api->config()->get('timezone'); ``` +API configuration applies globally. SDK users can override selected values for +one immutable resource chain with `Resource::withConfig()` without changing the +API-wide config. See [Resource-Local Configuration](04-resource-authoring.md#resource-local-configuration). + ### `setup()` ```php diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index c979902..9af8c16 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -328,6 +328,92 @@ 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. +## Resource-Local Configuration + +> **Available since version 3.1.0.** + +Resource-local configuration lets SDK authors build typed, immutable helpers +for options that affect both a request and its response context: + +```php +public function withLocale(string $locale): static +{ + return $this->withConfig([ + 'locale' => $locale, + ]); +} +``` + +SDK users then get an API-specific fluent method: + +```php +$user = $api->users()->withLocale('pt')->find(1); +``` + +`withConfig()` remains available as the generic escape hatch: + +```php +$user = $api + ->users() + ->withConfig(['locale' => 'pt']) + ->find(1); +``` + +The original resource and API-wide configuration remain unchanged. The +override belongs to the cloned resource, so reusing that resource applies it to +every request made through the clone: + +```php +$portugueseUsers = $api->users()->withLocale('pt'); + +$first = $portugueseUsers->find(1); +$second = $portugueseUsers->find(2); +``` + +Repeated calls merge their values, and later values win for the same key: + +```php +$users = $api + ->users() + ->withConfig(['locale' => 'en', 'timezone' => 'UTC']) + ->withConfig(['locale' => 'pt']); +``` + +The effective configuration contains `locale=pt` and `timezone=UTC`. +Non-overridden values come from the latest API-wide configuration, including +changes made after the resource was created: + +```text +Latest API-wide configuration +-> resource withConfig() overrides +``` + +Inside a resource, the scoped runtime exposes the effective configuration: + +```php +public function find(int $id): User +{ + return $this + ->endpoint() + ->query( + 'locale', + $this->runtime->config()->get('locale'), + ) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +} +``` + +Configuration is not automatically converted into query parameters or headers. +The resource author decides how each option maps to an endpoint. + +Inside resource methods, read scoped values with +`$this->runtime->config()->get()`. Do not call `set()` or `merge()` on that +scoped `Config`; apply changes by returning a clone through `withConfig()`. +The effective configuration is propagated through request and response hooks, +error handling, response mapping, entities, collections, and envelopes. This +keeps request construction and response interpretation consistent. + ## API-Specific Resource Chains Keep API-specific vocabulary out of the base package. Add it in SDK resources with small fluent methods that use the generic endpoint helpers underneath. @@ -376,7 +462,11 @@ $users = $api ->all(); ``` -Use the same pattern for API-specific concepts such as includes, filters, selects, pagination options, or locale settings. Clone the resource in `with*` methods so a configured chain does not leak into later calls. +Use the same pattern for API-specific concepts such as includes, filters, +selects, pagination options, or locale settings. Use `withConfig()` when the +value also affects request context or response interpretation. Clone the +resource directly for other API-specific request state so a configured chain +does not leak into later calls. ## Navigation diff --git a/docs/05-resources.md b/docs/05-resources.md index 238b19d..b10f57b 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -4,6 +4,38 @@ `Resource` keeps the SDK-user-facing domain surface small. SDK resource classes call `endpoint()` to start an endpoint request builder. +## Resource Configuration Overrides + +> **Available since version 3.1.0.** + +### `withConfig()` + +```php +withConfig(array $values): static +``` + +Returns a cloned resource with configuration values that override API-wide +configuration for that resource chain. + +```php +$users = $api + ->users() + ->withConfig(['timezone' => 'Europe/Lisbon']) + ->all(); +``` + +The override is available through the resource's scoped runtime and the request +context used by hooks, errors, responses, entities, collections, and envelopes. +It does not mutate the API-wide configuration or automatically add query +parameters or headers. + +Repeated calls preserve unrelated values. When the same key is supplied more +than once, the later value wins. Reusing the configured resource applies its +overrides to every request made through that cloned resource. + +See [Resource Authoring: Resource-Local Configuration](04-resource-authoring.md#resource-local-configuration) +for SDK-author helpers, request mapping, scope, and precedence. + ## Endpoint Builder ### `endpoint()` diff --git a/docs/06-responses.md b/docs/06-responses.md index 9730f7d..99ea090 100644 --- a/docs/06-responses.md +++ b/docs/06-responses.md @@ -128,6 +128,11 @@ Returns the SDK config available while hydrating entities or envelopes. $timezone = $context?->config()->get('timezone'); ``` +When a request is executed through a resource configured with `withConfig()`, +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). + ## `ErrorContext` `ErrorContext` is passed to configured error handlers. diff --git a/src/Resource.php b/src/Resource.php index 14d6d48..b3575bd 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -10,8 +10,12 @@ abstract class Resource { private PipelineOptions $pipelineOptions; + /** + * Runtime is replaceable so withConfig() can scope a cloned resource + * without mutating the original resource or API configuration. + */ public function __construct( - protected readonly Runtime $runtime + protected Runtime $runtime ) { $this->pipelineOptions = new PipelineOptions(); } @@ -26,6 +30,17 @@ public function withCache(callable $configure): static ); } + /** + * @param array $values + */ + public function withConfig(array $values): static + { + $clone = clone $this; + $clone->runtime = $this->runtime->withConfig($values); + + return $clone; + } + protected function endpoint(): Endpoint { return new Endpoint($this->runtime, $this->pipelineOptions); diff --git a/src/Runtime.php b/src/Runtime.php index 312ea75..56aaf6c 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -6,6 +6,7 @@ use ProgrammatorDev\Api\Config\Config; use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Context\ErrorContext; +use ProgrammatorDev\Api\Http\Transport; use ProgrammatorDev\Api\Request\PipelineOptions; use ProgrammatorDev\Api\Request\RequestOptions; use ProgrammatorDev\Api\Response\Response; @@ -15,23 +16,45 @@ final class Runtime { /** - * Transport is provided lazily so resources keep using the latest mutable API - * setup instead of the setup snapshot from when the resource was created. + * Transport is provided lazily so resources keep using the latest mutable API setup + * instead of the setup snapshot from when the resource was created. * - * @param \Closure(): \ProgrammatorDev\Api\Http\Transport $transport + * @param \Closure(): Transport $transport + * @param array $configOverrides */ public function __construct( private readonly Config $config, private readonly \Closure $transport, private readonly ResponseDecoder $responseDecoder, - private readonly ErrorBuilder $errorBuilder + private readonly ErrorBuilder $errorBuilder, + private readonly array $configOverrides = [] ) {} public function config(): Config { + if ($this->configOverrides !== []) { + // Merge lazily so scoped resources see later API config changes + // while keeping their overrides isolated from the shared configuration. + return (clone $this->config)->merge($this->configOverrides); + } + return $this->config; } + /** + * @param array $values + */ + public function withConfig(array $values): self + { + return new self( + config: $this->config, + transport: $this->transport, + responseDecoder: $this->responseDecoder, + errorBuilder: $this->errorBuilder, + configOverrides: array_merge($this->configOverrides, $values) + ); + } + /** * @throws ClientExceptionInterface * @throws \JsonException @@ -45,7 +68,7 @@ public function send( RequestOptions $requestOptions, PipelineOptions $pipelineOptions ): Response { - $context = new Context($this->config); + $context = new Context($this->config()); $rawResponse = ($this->transport)()->send( method: $method, diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php index 3d4a48c..5d7aa3f 100644 --- a/tests/Integration/CacheTest.php +++ b/tests/Integration/CacheTest.php @@ -98,4 +98,54 @@ public function testResourceCacheOverrideRequiresGlobalCacheConfiguration(): voi ->withCache(fn($cache) => $cache->defaultTtl(60)) ->find(1); } + + public function testCachedResponsesUseCurrentResourceConfig(): void + { + $client = $this->mockClient(new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":1,"name":"John"}' + )); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->defaultTtl(60); + + $utc = $api + ->users() + ->withConfig(['timezone' => 'UTC']) + ->find(1); + + $lisbon = $api + ->users() + ->withConfig(['timezone' => 'Europe/Lisbon']) + ->find(1); + + $this->assertSame('UTC', $utc->getTimezone()); + $this->assertSame('Europe/Lisbon', $lisbon->getTimezone()); + $this->assertCount(1, $client->getRequests()); + } + + public function testResourceConfigAndCacheOverridesComposeInEitherOrder(): void + { + $client = $this->mockClient( + new Response(body: '{"id":1,"name":"John"}'), + new Response(body: '{"id":2,"name":"Jane"}') + ); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->methods(['GET']); + + $first = $api + ->users() + ->withConfig(['timezone' => 'Europe/Lisbon']) + ->withCache(fn($cache) => $cache->methods([])) + ->find(1); + + $second = $api + ->users() + ->withCache(fn($cache) => $cache->methods([])) + ->withConfig(['timezone' => 'Europe/Lisbon']) + ->find(2); + + $this->assertSame('Europe/Lisbon', $first->getTimezone()); + $this->assertSame('Europe/Lisbon', $second->getTimezone()); + $this->assertCount(2, $client->getRequests()); + } } diff --git a/tests/Integration/ErrorHandlingTest.php b/tests/Integration/ErrorHandlingTest.php index e712585..0c41ba0 100644 --- a/tests/Integration/ErrorHandlingTest.php +++ b/tests/Integration/ErrorHandlingTest.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\Api\Test\Integration; use Nyholm\Psr7\Response; +use ProgrammatorDev\Api\Context\ErrorContext; use ProgrammatorDev\Api\Test\Fixture\InvalidApiKeyException; use ProgrammatorDev\Api\Test\Fixture\JsonApi; use ProgrammatorDev\Api\Test\Fixture\NotFoundException; @@ -82,4 +83,21 @@ public function testConfiguredCustomErrorHandlerDoesNotThrowWhenUnmatched(): voi $this->assertSame(401, $response->raw()->getStatusCode()); $this->assertSame(['code' => 'rate_limited', 'message' => 'Too many requests'], $response->data()); } + + public function testErrorHandlersReceiveResourceConfigOverrides(): void + { + $client = $this->mockClient(new Response(status: 422, body: '{"message":"Invalid"}')); + $api = new JsonApi($client); + $api->setup()->errors()->status( + 422, + fn (ErrorContext $context): \Throwable => new \RuntimeException( + $context->apiContext()->config()->get('error_message') + ) + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Scoped error'); + + $api->raw()->withConfig(['error_message' => 'Scoped error'])->fetch(); + } } diff --git a/tests/Integration/HookTest.php b/tests/Integration/HookTest.php index 8b9d1e5..d1c19bf 100644 --- a/tests/Integration/HookTest.php +++ b/tests/Integration/HookTest.php @@ -80,6 +80,48 @@ public function testHooksCanReadSdkConfig(): void $this->assertSame('acme', $client->getLastRequest()->getHeaderLine('X-Tenant')); } + public function testHooksReceiveResourceConfigOverrides(): void + { + $client = $this->mockClient(new Response(body: '{"ok":true}')); + $seen = []; + + $api = new JsonApi($client); + $api->beforeRequest( + function (RequestContext $context) use (&$seen) { + $config = $context->apiContext()->config(); + $seen['before'] = [ + 'tenant' => $config->get('tenant'), + 'region' => $config->get('region'), + ]; + + return $context->request()->withHeader('X-Tenant', $seen['before']['tenant']); + } + ); + $api->afterResponse( + function (ResponseContext $context) use (&$seen): void { + $config = $context->apiContext()->config(); + $seen['after'] = [ + 'tenant' => $config->get('tenant'), + 'region' => $config->get('region'), + ]; + } + ); + + $api + ->raw() + ->withConfig(['tenant' => 'acme']) + ->withConfig(['region' => 'eu']) + ->fetch(); + + $this->assertSame('acme', $client->getLastRequest()->getHeaderLine('X-Tenant')); + $this->assertSame([ + 'before' => ['tenant' => 'acme', 'region' => 'eu'], + 'after' => ['tenant' => 'acme', 'region' => 'eu'], + ], $seen); + $this->assertFalse($api->config()->has('tenant')); + $this->assertFalse($api->config()->has('region')); + } + public function testBeforeRequestHookRejectsInvalidReturnValue(): void { $this->expectException(UnexpectedValueException::class); diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 1764368..430c44b 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -196,16 +196,84 @@ public function testResourceCanReadSdkConfig(): void $this->assertSame('https://api.example.com/users/1?locale=en&timezone=UTC', (string) $this->client->getLastRequest()->getUri()); } - public function testResourceCreatedBeforeSetupChangeUsesLatestRequestDefaults(): void + public function testResourceConfigOverridesAreImmutableAndRequestLocal(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); $users = $this->api->users(); + $configured = $users->withConfig(['timezone' => 'Europe/Lisbon']); + + $configuredUser = $configured->findWithConfiguredTimezone(1); + $defaultUser = $users->findWithConfiguredTimezone(2); + $requests = $this->client->getRequests(); + + $this->assertNotSame($users, $configured); + $this->assertSame('Europe/Lisbon', $configuredUser->getTimezone()); + $this->assertSame('UTC', $defaultUser->getTimezone()); + $this->assertSame('UTC', $this->api->config()->get('timezone')); + $this->assertSame('Europe/Lisbon', $this->queryFromRequest($requests[0])['timezone']); + $this->assertSame('UTC', $this->queryFromRequest($requests[1])['timezone']); + } + + public function testLaterResourceConfigOverridesWin(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $user = $this->api + ->users() + ->withConfig(['timezone' => 'America/New_York']) + ->withConfig(['timezone' => 'Europe/Lisbon']) + ->findWithConfiguredTimezone(1); + + $this->assertSame('Europe/Lisbon', $user->getTimezone()); + } + + public function testResourceConfigUsesLateApiChangesForNonOverriddenValues(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $users = $this->api + ->users() + ->withConfig(['tenant' => 'acme']); + + $this->api->config(['timezone' => 'Europe/Lisbon']); + + $user = $users->findWithConfiguredTimezone(1); + + $this->assertSame('Europe/Lisbon', $user->getTimezone()); + } + + public function testResourceConfigReachesCollectionsAndEnvelopes(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"}]}')); + $this->client->addResponse(new Response(body: '{"data":{"id":2,"name":"Jane"}}')); + + $users = $this->api + ->users() + ->withConfig(['timezone' => 'Europe/Lisbon']); + + $collection = $users->all(); + $envelope = $users->findEnvelope(2); + + $this->assertSame('Europe/Lisbon', $collection[0]->getTimezone()); + $this->assertSame('Europe/Lisbon', $envelope->getTimezone()); + $this->assertSame('Europe/Lisbon', $envelope->getUser()->getTimezone()); + } + + public function testScopedResourceCreatedBeforeSetupChangeUsesLatestRequestDefaults(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $users = $this->api + ->users() + ->withConfig(['timezone' => 'Europe/Lisbon']); $this->api->setup()->defaultQuery('units', 'metric'); - $users->find(1); + $user = $users->find(1); + $this->assertSame('Europe/Lisbon', $user->getTimezone()); $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $this->client->getLastRequest()->getUri()); } @@ -271,4 +339,11 @@ public static function resourceVerbProvider(): array 'trace' => ['TRACE'], ]; } + + private function queryFromRequest(\Psr\Http\Message\RequestInterface $request): array + { + parse_str($request->getUri()->getQuery(), $query); + + return $query; + } }