Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/03-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 91 additions & 1 deletion docs/04-resource-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions docs/05-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down
5 changes: 5 additions & 0 deletions docs/06-responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion src/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -26,6 +30,17 @@ public function withCache(callable $configure): static
);
}

/**
* @param array<string, mixed> $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);
Expand Down
33 changes: 28 additions & 5 deletions src/Runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, mixed> $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<string, mixed> $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
Expand All @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions tests/Integration/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
18 changes: 18 additions & 0 deletions tests/Integration/ErrorHandlingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
Loading