diff --git a/.gitignore b/.gitignore index 7fd3a28..c47593f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ /.idea /index.php /src/TestApi.php -/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da92246 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,198 @@ +# AGENTS.md + +## Project Purpose + +`programmatordev/php-api-sdk` is a lightweight foundation for building fluent, +maintainable PHP API SDKs. It should make common SDK work compact and enjoyable +without hiding the request lifecycle or becoming a heavy framework. + +The package serves two developer audiences: + +- SDK authors extend the package to build concrete API SDKs. +- SDK users consume those concrete SDKs. + +Favor the SDK-author experience when choosing internal extension points and +authoring APIs. Keep the SDK-user surface focused on real resources and endpoint +methods, with deliberate escape hatches for advanced use cases. + +## Core Concepts + +Keep the architecture centered on a small set of clear responsibilities: + +- `Api`: the SDK facade, resource entry point, and author-owned configuration + surface. +- `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. +- `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. +- `Response`: the decoded/raw response wrapper and mapping surface. +- `Entity`: the optional contract for typed response data objects. + +Do not blur these responsibilities without a concrete simplification. In +particular, keep setup concerns out of resources and API-specific behavior out of +the generic runtime. + +## Authoring Experience + +Keep the common resource path compact: + +```php +return $this + ->endpoint() + ->get('/path/{id}', ['id' => $id]) + ->entity(User::class); +``` + +Prefer fluent SDK authoring over low-level request construction inside +resources. Advanced PSR capabilities should remain available without dominating +the basic workflow. + +Expose advanced SDK-user setup through one obvious surface: + +```php +$api->setup()->plugins()->add($plugin); +$api->setup()->client($client); +$api->setup()->auth()->bearer($token); +``` + +Keep SDK-author setup helpers protected on `Api` by default. This preserves a +focused SDK-user autocomplete surface while allowing concrete SDKs to provide +purpose-built public configuration methods. + +`send()` may remain public as an advanced escape hatch for endpoints not modeled +by a concrete SDK. It must still use the configured authentication, plugins, +cache, hooks, decoding, and error handling. + +## Design Principles + +- Keep the package small, explicit, and composable. Add an abstraction only when + it removes real complexity, improves SDK authoring, or supports an established + capability. +- Hackability is a feature. SDK users may intentionally customize the runtime + through `setup()`; that power should be explicit rather than hidden. +- Builders are mutable configuration objects. Fluent builder methods configure + state, while methods returning stored or built data use `get*()` names. +- Resources and endpoints are immutable request scopes. Fluent modifiers must + return clones and must not leak state into the API, sibling resources, or + later requests. +- Scoped overrides should retain access to the current API runtime and merge + lazily. Do not snapshot shared configuration or runtime services when only one + value needs to be overridden. +- One effective configuration must flow through request creation, hooks, + transport, errors, responses, and hydration. Different stages must not observe + different values for the same request. +- Independent modifiers should compose in any order unless ordering is an + intentional part of their contract. Applying one modifier must not discard + another modifier's state. +- Normalize ergonomic author-facing values at the narrowest shared boundary. + For example, normalize supported request values after defaults and endpoint + 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 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. +- Avoid architecture that requires constant dependency injection or repetitive + boilerplate in downstream SDKs. + +## Compatibility And Capabilities + +Preserve backward compatibility by default. Do not remove, rename, or change the +meaning of public APIs or protected SDK-author extension points without explicit +approval for a breaking release. + +When extending behavior: + +- Prefer additive APIs and compatible normalization at existing boundaries. +- Preserve established defaults and merge precedence. +- Keep original objects unchanged when introducing scoped fluent behavior. +- Call out any unavoidable break explicitly before implementation. +- Document a migration path for every approved breaking change. + +Maintain support for the package's core capabilities: + +- PSR-18 HTTP clients. +- PSR-17 request and stream factories. +- PSR-6 caches. +- PSR-3 loggers. +- Authentication. +- Plugins and middleware. +- Request and response hooks. +- Query and header defaults. +- Base URL and path construction. +- Response decoding and transformation. +- Error handling. +- Test utilities for SDK authors where they provide clear value. + +## Implementation Approach + +- Read adjacent code and tests before editing. Follow existing naming, fluent + patterns, typing, and file organization. +- Prefer the smallest coherent change that solves the current problem. +- Reuse existing helpers and extension points before creating new layers. +- Keep internal and public APIs consistent in terminology and return behavior. +- Add comments only for non-obvious constraints, ordering, isolation, or design + decisions. Do not narrate self-explanatory code. +- Treat request construction, execution, and response mapping as one pipeline. + Changes at one stage must be checked for effects on the others. + +## Documentation + +Update documentation alongside every user-visible or SDK-author-visible behavior +change. + +Documentation should explain: + +- The core concepts and their responsibilities. +- 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 to configure authentication, clients, factories, cache, logging, plugins, + hooks, and errors. +- How to create API-specific fluent helpers on top of generic primitives. +- Availability versions for newly introduced features when relevant. + +Use focused documents as topics grow. Prefer clear navigation and concise, +complete examples over a single large guide. Keep examples API-neutral unless a +real downstream SDK is being used as an integration proof. + +For breaking releases, provide an upgrade guide that identifies each changed +contract and its replacement path. Do not create version-specific upgrade guides +for additive minor releases. + +## Testing + +Add or update tests with every meaningful behavior change. Test public behavior +and supported extension points rather than private implementation details. + +Use fixtures, fake APIs, test resources, mock clients, and local response objects +to represent realistic SDK authoring and usage. Cover both: + +- Base package behavior. +- SDK-author behavior through small concrete SDK fixtures. + +For scoped or pipeline behavior, verify isolation and propagation explicitly: + +- Original and sibling instances remain unchanged. +- Defaults and local overrides merge with documented precedence. +- Later runtime configuration remains visible where it is not overridden. +- Hooks, errors, responses, and hydration observe the same effective context. +- Cache behavior does not leak request-local state. +- Independent fluent modifiers compose correctly. + +## Downstream Validation + +Validate important design decisions against representative downstream SDKs, +including both simple integrations and complex integrations with resources, +envelopes, metadata, pagination, filtering, and many entities. + +The base package should make both styles straightforward without becoming +coupled to either API. A downstream friction point is evidence to evaluate, not +automatic justification for adding API-specific behavior to the core. diff --git a/docs/03-api.md b/docs/03-api.md index e97487e..4f12e40 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -39,6 +39,12 @@ $response = $api->send( ); ``` +> **Backed-enum normalization is available since version 3.1.0.** + +The `query` and `headers` arrays accept string- and integer-backed enums. Query +parameters use their backed values, while header values are converted to strings +as required by PSR-7. + Path parameters are encoded and replaced in `{name}` placeholders. `send()` still runs through the configured SDK pipeline: @@ -75,6 +81,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 @@ -173,6 +183,13 @@ $this->defaultHeaders(['Accept' => 'application/json']); Header names are not normalized by the package. +> **Backed-enum normalization is available since version 3.1.0.** + +String- and integer-backed enums can be used as default query or header values. +Their scalar values are used when the request is built. Normalization also +applies recursively to nested query values and header value lists. Header values +are converted to strings as required by PSR-7. + ## Pipeline Builders ### `auth()` diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index c979902..235f478 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -95,6 +95,45 @@ return $this ->collection(User::class, key: 'data'); ``` +### Backed Enum Values + +> **Available since version 3.1.0.** + +String- and integer-backed enums can be passed directly as query parameters or +header values: + +```php +enum Status: string +{ + case ACTIVE = 'active'; + case PENDING = 'pending'; +} + +enum Visibility: int +{ + case PUBLIC = 1; +} + +return $this + ->endpoint() + ->queries([ + 'status' => Status::ACTIVE, + 'filter' => ['visibility' => Visibility::PUBLIC], + ]) + ->headers([ + 'X-Status' => Status::ACTIVE, + 'X-Allowed-Statuses' => [Status::ACTIVE, Status::PENDING], + ]) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +The backed values are normalized recursively after API defaults and endpoint +options are merged. This applies to endpoint values, API-level defaults, +nested query arrays, and header value lists. Header values are converted to +strings as required by PSR-7. Unit enums are not supported as request values; +pass an explicit scalar value instead. + SDK-user customization should be explicit in the resource method API. If a method argument is enough, prefer that over hidden resource state: ```php @@ -328,6 +367,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 +501,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..ad87a64 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()` @@ -145,6 +177,33 @@ return $this ->raw(); ``` +### Backed Enum Values + +> **Available since version 3.1.0.** + +`query()`, `queries()`, `header()`, and `headers()` accept string- and +integer-backed enums. The request uses each enum's scalar value, including in +nested query arrays and header value lists: + +```php +return $this + ->endpoint() + ->queries([ + 'status' => Status::ACTIVE, + 'filter' => ['visibility' => Visibility::PUBLIC], + ]) + ->headers([ + 'X-Status' => Status::ACTIVE, + 'X-Allowed-Statuses' => [Status::ACTIVE, Status::PENDING], + ]) + ->get('/users'); +``` + +The same normalization applies to values configured through API-level +`defaultQuery()`, `defaultQueries()`, `defaultHeader()`, and `defaultHeaders()`. +Header values are converted to strings as required by PSR-7. Unit enums are not +supported as request values; pass an explicit scalar value instead. + ## Endpoint HTTP Methods Endpoint HTTP helpers execute the request immediately and return `Response`: 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/Http/Transport.php b/src/Http/Transport.php index f0e3319..0445439 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -75,6 +75,13 @@ public function send( $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), @@ -99,8 +106,8 @@ private function buildPlugins(PipelineOptions $pipelineOptions): array { $plugins = new PluginBuilder(); - // Internal plugins are registered before user plugins so custom plugins can - // still run before, between, or after them by choosing a priority. + // Internal plugins are registered before user plugins + // so custom plugins can still run before, between, or after them by choosing a priority. $plugins->add( plugin: new ContentTypePlugin(), priority: self::CONTENT_TYPE_PLUGIN_PRIORITY @@ -147,8 +154,8 @@ private function buildCachePlugin(PipelineOptions $pipelineOptions): ?Plugin return null; } - // Request-local pipeline options adjust a clone so endpoint defaults and - // resource overrides do not leak into the API-level cache configuration. + // Request-local pipeline options adjust a clone so endpoint defaults and resource overrides + // do not leak into the API-level cache configuration. $cacheBuilder = clone $this->cacheBuilder; $pipelineOptions->applyTo(PipelineOption::CACHE, $cacheBuilder); @@ -195,6 +202,26 @@ private function buildPath(string $path, array $parameters): string return $path; } + /** + * @param bool $stringify Convert backed values to strings for PSR-7 headers. + */ + private function normalizeBackedEnums(mixed $value, bool $stringify = false): mixed + { + if ($value instanceof \BackedEnum) { + return $stringify ? (string) $value->value : $value->value; + } + + if (!is_array($value)) { + return $value; + } + + // Query structures can be nested and header values can be lists. + return array_map( + fn(mixed $item): mixed => $this->normalizeBackedEnums($item, $stringify), + $value + ); + } + private function buildUrl(string $path, array $query = []): string { $query = array_filter($query, static fn(mixed $value): bool => $value !== null); 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/Fixture/StrictHeaderRequestFactory.php b/tests/Fixture/StrictHeaderRequestFactory.php new file mode 100644 index 0000000..31a28e7 --- /dev/null +++ b/tests/Fixture/StrictHeaderRequestFactory.php @@ -0,0 +1,30 @@ +entity(User::class); } + public function findWithRequestOptions( + int|string $id, + array $query = [], + array $headers = [] + ): User + { + return $this + ->endpoint() + ->queries($query) + ->headers($headers) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + public function findWithConfiguredTimezone(int|string $id): User { return $this diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 7b6983a..8a86b87 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -62,6 +62,23 @@ public function testApiCanSendPublicRequestWithQueryHeadersAndBody(): void $this->assertSame('{"name":"John"}', (string) $request->getBody()); } + public function testApiCanSendPublicRequestWithBackedEnums(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + + (new FakeApi($client))->send( + method: Method::GET, + path: '/users', + query: ['status' => ApiRequestValue::ACTIVE], + headers: ['X-Status' => ApiRequestValue::ACTIVE] + ); + + $request = $client->getLastRequest(); + + $this->assertSame('active', $this->queryFromLastRequest($client)['status']); + $this->assertSame('active', $request->getHeaderLine('X-Status')); + } + public function testApiCanSendRequestWithDefaultQuery(): void { $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); @@ -199,3 +216,8 @@ public function testApiSendUsesConfiguredCache(): void $this->assertCount(1, $client->getRequests()); } } + +enum ApiRequestValue: string +{ + case ACTIVE = 'active'; +} 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..8a396ad 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -7,6 +7,7 @@ use Nyholm\Psr7\Stream; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use ProgrammatorDev\Api\Test\Fixture\FakeApi; +use ProgrammatorDev\Api\Test\Fixture\StrictHeaderRequestFactory; use ProgrammatorDev\Api\Test\Fixture\User; use ProgrammatorDev\Api\Test\Fixture\UserEnvelope; @@ -113,6 +114,65 @@ public function testEndpointCanSetRequestQueryAndHeaders(): void $this->assertSame('acme', $request->getHeaderLine('X-Tenant')); } + public function testEndpointNormalizesBackedEnumsInQueryAndHeaders(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $this->api->setup()->client($this->client)->requestFactory(new StrictHeaderRequestFactory()); + + $this->api->users()->findWithRequestOptions( + id: 1, + query: [ + 'status' => StringRequestValue::ACTIVE, + 'filter' => [ + 'page' => IntegerRequestValue::SECOND, + 'statuses' => [StringRequestValue::ACTIVE], + ], + 'nullable' => null, + 'enabled' => false, + 'offset' => 0, + 'search' => '', + ], + headers: [ + 'X-Status' => StringRequestValue::ACTIVE, + 'X-Values' => [StringRequestValue::ACTIVE, IntegerRequestValue::SECOND], + ] + ); + + $request = $this->client->getLastRequest(); + $query = $this->queryFromRequest($request); + + $this->assertSame('active', $query['status']); + $this->assertSame('2', $query['filter']['page']); + $this->assertSame(['active'], $query['filter']['statuses']); + $this->assertArrayNotHasKey('nullable', $query); + $this->assertSame('0', $query['enabled']); + $this->assertSame('0', $query['offset']); + $this->assertSame('', $query['search']); + $this->assertSame('active', $request->getHeaderLine('X-Status')); + $this->assertSame(['active', '2'], $request->getHeader('X-Values')); + } + + public function testRequestDefaultsNormalizeBackedEnums(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->withDefaultQuery('status', StringRequestValue::ACTIVE) + ->withDefaultQuery('pagination', ['page' => IntegerRequestValue::SECOND]) + ->withDefaultHeader('X-Status', StringRequestValue::ACTIVE) + ->withDefaultHeader('X-Values', [StringRequestValue::ACTIVE, IntegerRequestValue::SECOND]) + ->users() + ->find(1); + + $request = $this->client->getLastRequest(); + $query = $this->queryFromRequest($request); + + $this->assertSame('active', $query['status']); + $this->assertSame('2', $query['pagination']['page']); + $this->assertSame('active', $request->getHeaderLine('X-Status')); + $this->assertSame(['active', '2'], $request->getHeader('X-Values')); + } + public function testResourceBodyRejectsArrayData(): void { $this->expectException(\InvalidArgumentException::class); @@ -196,16 +256,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 +399,21 @@ public static function resourceVerbProvider(): array 'trace' => ['TRACE'], ]; } + + private function queryFromRequest(\Psr\Http\Message\RequestInterface $request): array + { + parse_str($request->getUri()->getQuery(), $query); + + return $query; + } +} + +enum StringRequestValue: string +{ + case ACTIVE = 'active'; +} + +enum IntegerRequestValue: int +{ + case SECOND = 2; }