From 2484ece6ca55d24d747914e92ffee0e276c4fda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 31 May 2026 19:42:38 +0100 Subject: [PATCH 01/88] docs(v3): outline architecture plan --- .gitignore | 3 +- docs/v3-architecture-plan.md | 533 +++++++++++++++++++++++++++++++++++ 2 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 docs/v3-architecture-plan.md diff --git a/.gitignore b/.gitignore index 82321d0..7fd3a28 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /logs/ /.idea /index.php -/src/TestApi.php \ No newline at end of file +/src/TestApi.php +/AGENTS.md diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md new file mode 100644 index 0000000..6512cb6 --- /dev/null +++ b/docs/v3-architecture-plan.md @@ -0,0 +1,533 @@ +# v3 Architecture Plan + +## Goal + +`v3.0` should make this package easier and more enjoyable for SDK authors while preserving the power of `v2.x`. + +The target experience is fluent and compact: + +```php +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } +} +``` + +SDK users should interact with purpose-built resources and entities, not raw request primitives: + +```php +$user = $api->users()->find(1); +``` + +## Core Concepts + +### `Api` + +The SDK facade and configuration surface. + +Responsibilities: + +- Configure base URL. +- Configure authentication. +- Configure global SDK options through a generic config bag. +- Configure global query and header defaults. +- Configure PSR clients and factories. +- Configure cache, logger, plugins, request hooks, response hooks, decoding, and errors. +- Expose resources through a protected/public SDK method pattern. + +Non-goals: + +- It should not require final SDK users to call raw HTTP request methods. +- It should not accumulate API-specific query concepts like `include`, `select`, `filter`, or pagination. + +`Api` should be abstract. It is a base class for concrete SDKs, not something users instantiate directly. It does not need to force subclasses to implement an abstract method in the first phase. + +`config()` should act as both setter and getter: + +```php +$this->config(['timezone' => 'UTC']); +$timezone = $this->config()->get('timezone'); +``` + +### `Resource` + +The base class for endpoint groups. + +Responsibilities: + +- Provide protected HTTP helpers like `get`, `post`, `put`, `patch`, and `delete`. +- Hold immutable per-resource request options. +- Allow generic query/header customization through primitives like `query`, `queries`, `header`, and `headers`. +- Return a fresh resource instance by default when created through `Api::resource()`. +- Execute requests immediately when `get`, `post`, `put`, `patch`, or `delete` are called. + +Non-goals: + +- It should not know about API-specific concepts. +- API-specific packages should add domain vocabulary through their own base resources or traits. +- Resource instance caching is not part of the first v3 slice. This is unrelated to PSR-6 HTTP response caching, which remains a feature parity requirement. + +### `RequestOptions` + +Immutable request state used by resources. + +Responsibilities: + +- Store per-resource/per-request query parameters. +- Store per-resource/per-request headers. +- Store body/payload options when needed. +- Merge cleanly with API defaults during request execution. + +This avoids cloning and mutating the whole API instance for resource modifiers. + +Resource options should be configured fluently before calling the HTTP method: + +```php +return $this + ->query('active', true) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +``` + +The path remains an argument of `get`, `post`, `put`, `patch`, and `delete`. Query and header options are configured through fluent resource methods. + +Query merge order: + +```text +global API defaults < resource options < endpoint-specific options +``` + +Resource constructors may remain public. SDK authors should usually expose resources through `Api::resource()`, but direct construction is useful for testing and advanced use. + +### `Response` + +Wrapper around decoded data and the raw PSR response. + +Responsibilities: + +- Expose decoded data. +- Expose raw PSR response when needed. +- Map data to entities. +- Map list data to collections. +- Preserve response metadata when APIs return envelopes. +- Carry response context, including SDK config, for entity and envelope hydration. + +Custom envelopes should implement: + +```php +interface ResponseEnvelope +{ + public static function fromResponse(Response $response): static; +} +``` + +`Response::as()` should require `ResponseEnvelope`. + +The wrapper should be named `Response`, not `ApiResponse`. PSR responses are referenced through `ResponseInterface`, so the shorter package name is acceptable and keeps SDK authoring readable. + +SDK authors may choose whether endpoint methods return entities directly or response envelope classes: + +```php +public function find(int $id): User +{ + return $this->get('/users/{id}', ['id' => $id])->entity(User::class, key: 'data'); +} + +public function findWithMeta(int $id): UserResponse +{ + return $this->get('/users/{id}', ['id' => $id])->as(UserResponse::class); +} +``` + +### `Entity` + +Required contract for typed response objects used by `Response::entity()` and `Response::collection()`. + +Responsibilities: + +- Provide a simple convention for hydrating typed objects. +- Keep entities as data/value objects by default. +- Make entity mapping requirements explicit for SDK authors. + +Non-goals: + +- No lazy loading in the first phase. +- No hidden network calls from entity getters. + +Proposed contract: + +```php +interface Entity +{ + public static function fromArray(array $data, ?EntityContext $context = null): static; +} +``` + +`EntityContext` should provide access to SDK config and response context without injecting the full `Api` into entities. + +Start with a minimal context API. Add richer access only when implementation needs it. + +`toArray()` is not required for v3 entity mapping. It can be added by individual SDK entities or a future optional interface if serialization becomes a real package concern. + +## v2 Public Surface Inventory + +### `Api` + +Current public methods: + +- `request` +- `getBaseUrl` +- `setBaseUrl` +- `getQueryDefault` +- `addQueryDefault` +- `removeQueryDefault` +- `getHeaderDefault` +- `addHeaderDefault` +- `removeHeaderDefault` +- `getClientBuilder` +- `setClientBuilder` +- `getCacheBuilder` +- `setCacheBuilder` +- `getLoggerBuilder` +- `setLoggerBuilder` +- `getAuthentication` +- `setAuthentication` +- `addPreRequestListener` +- `addPostRequestListener` +- `addResponseContentsListener` +- `buildPath` + +Observations: + +- The current `Api` class is easy to extend but exposes low-level request execution directly. +- SDK packages currently use `request` and `buildPath` from resources. +- Defaults are global to the API instance, which makes resource-level fluent options awkward. +- JSON decoding and error handling are implemented through listeners. +- Plugin configuration is automatic inside `request`, which can duplicate responsibilities and makes request flow harder to reason about. +- Symfony EventDispatcher provides flexibility, but common behavior like JSON decoding and error mapping should not require event listeners in v3. + +### Builders + +Current public builder classes: + +- `ClientBuilder` +- `CacheBuilder` +- `LoggerBuilder` + +Current capabilities: + +- PSR-18 client discovery and injection. +- PSR-17 request and stream factory discovery and injection. +- Plugin registration by priority. +- PSR-6 cache configuration. +- PSR-3 logger and formatter configuration. + +These capabilities must remain available in v3. + +HTTPlug's `PluginClientBuilder` already supports priority ordering and multiple plugins at the same priority. v3 should use that behavior directly or mirror it closely. The current v2 `ClientBuilder` stores plugins as `[priority => plugin]`, which means plugins with the same priority overwrite each other. + +### Events + +Current event classes: + +- `PreRequestEvent` +- `PostRequestEvent` +- `ResponseContentsEvent` + +Current capabilities: + +- Mutate request before sending. +- Mutate response after sending. +- Transform response contents. + +These capabilities should remain, but v3 should consider clearer first-class APIs for common cases: + +- JSON decoding. +- Error mapping. +- Request hooks. +- Response hooks. + +Decision: v3 should replace the Symfony EventDispatcher dependency with a smaller request/response pipeline. The pipeline should still support request hooks, response hooks, and response transformation, but common features should be first-class fluent APIs. + +Hooks should receive lightweight context objects rather than long argument lists: + +```php +$this->hooks()->beforeRequest( + fn (RequestContext $context) => $context->request() +); + +$this->hooks()->afterResponse( + fn (ResponseContext $context) => $context->rawResponse() +); +``` + +Request/response contexts should expose the request, response where applicable, and SDK config. + +Hooks should use return-object semantics: + +```php +$this->hooks()->beforeRequest( + fn (RequestContext $context) => $context->request()->withHeader('X-Trace-Id', 'abc') +); +``` + +The returned object replaces the current request/response/data. Returning `null` means no change. This matches PSR-7 immutability and avoids mutable event/context objects. + +Initial pipeline order: + +```text +create request +beforeRequest hooks +send request +afterResponse hooks +decode body +create Response wrapper +error handling +transform hooks +return Response +``` + +Error handling should run before transform hooks so API-specific error mapping sees the original decoded API response shape. + +### Helpers and Test Utilities + +Current helpers: + +- `StringHelper::reduceDuplicateSlashes` +- `StringHelper::isUrl` +- `Method` constants. + +Current test utilities: + +- `AbstractTestCase` +- `MockResponse` +- `TestApi` + +The v3 test utilities should focus on helping SDK authors test resources, responses, and entity mapping. + +## v3 Replacement Map + +| v2 capability | Proposed v3 shape | +| --- | --- | +| Public `Api::request` | Removed; protected/internal transport used by `Resource` helpers | +| `Api::buildPath` | Path parameter replacement inside `Resource`/transport `get('/x/{id}', ['id' => $id])` | +| `setBaseUrl` / `getBaseUrl` | Fluent `baseUrl(...)`, optional getter only if useful | +| SDK-specific global options | Generic config bag exposed to resources/responses/entities through context | +| Query/header defaults | Fluent `queryDefaults(...)`, `headerDefaults(...)` | +| Per-resource query options | New `RequestOptions`, exposed through `Resource::query(...)` and SDK-specific traits | +| `setAuthentication` | Fluent `auth()` helper wrapping HTTPlug authentication plus low-level authentication injection | +| Client/factory injection | Keep builder-style or fluent config methods | +| Plugins | Use HTTPlug `PluginClientBuilder`-style priority handling; preserve multiple plugins at the same priority | +| Cache | Keep PSR-6 support, likely through fluent `cache(...)` | +| Logger | Keep PSR-3 support, likely through fluent `logger(...)` | +| `ResponseContentsEvent` for JSON | First-class `responses()->json()` | +| Post-response listener for errors | First-class status and callback-based error mapping, while preserving hooks | +| Raw response body return | `Response::data()` or `Response::raw()` depending on configuration | +| Manual entity construction in resources | `Response::entity(...)`, `Response::collection(...)`, and `Response::as(...)` helpers | + +## Decisions + +- Resource instances should not be cached by default. +- PSR-6 HTTP response caching remains a separate feature. +- `Api` should be abstract. +- v3 should remove old v2 public low-level methods instead of keeping deprecated aliases. +- SDK-wide options should be stored in a generic config bag. +- SDK config should be available through context objects, not by injecting `Api` into entities. +- `Response::entity()` and `Response::collection()` should require classes that implement `Entity`. +- `Response::as()` should support API-specific response envelope classes such as SportMonks item and collection responses. +- `Response::as()` should require a `ResponseEnvelope` contract with `fromResponse(Response $response)`. +- `Response::collection()` should return a plain array by default. +- Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. +- Symfony EventDispatcher should be replaced with a smaller request/response pipeline. +- Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. +- Prefer fluent configuration over public getters. +- Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. +- Keep `Resource::query()`, `Resource::queries()`, `Resource::header()`, and `Resource::headers()` as generic public primitives. +- Resource modifiers should be immutable and return cloned resources. +- `get`, `post`, `put`, `patch`, and `delete` should execute immediately. +- SDK authors choose whether resource methods return entities directly or custom response envelopes. +- Resource constructors may remain public. +- Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::as()`. +- No reset methods for resource options in the first phase. +- Merge order should be global defaults, then resource options, then endpoint-specific options. +- Header names should not be normalized manually. +- Path parameters should be encoded with `rawurlencode`. +- Query strings should use `http_build_query(..., PHP_QUERY_RFC3986)`. +- Null query values should be omitted by default. +- Boolean query values should use standard `http_build_query` behavior. +- Full URL paths should continue to override the configured base URL. +- Invalid JSON should throw when JSON decoding is enabled. +- Empty JSON response bodies should decode to `null` without throwing. +- Pipeline order should be request hooks, send, response hooks, decode, response wrapper, errors, transforms, return. +- Hooks should return replacement objects/data. Returning `null` means no change. +- Error handling should support both status maps and custom callbacks. +- Error callbacks should receive an `ErrorContext` object and return a `Throwable` when matched or `null` when not matched. +- Fluent auth helpers should wrap existing HTTPlug authentication objects. +- Fluent config should use grouped builders such as `auth()`, `responses()`, `errors()`, `plugins()`, `cache()`, `logger()`, and `hooks()`. +- `auth()` should mirror and wrap HTTPlug authentication behavior rather than inventing new authentication primitives. +- `Response::entity()` and `Response::collection()` should support an optional key for extracting entity data from decoded response envelopes. +- `Response::as()` should receive the full decoded `Response`, leaving envelope classes responsible for extracting their data. +- Response data access should stay simple in the first phase. No dot notation or nested key helpers. +- Request body helpers should be friendly for SDK authors while converting to PSR-7 streams internally. +- Resource body helpers should be fluent: `json()`, `form()`, and `body()`. +- Passing an array to `body()` should throw; SDK authors should choose `json()` or `form()` explicitly. +- `json()` should set `Content-Type: application/json`. +- `form()` should set `Content-Type: application/x-www-form-urlencoded`. +- `body(string|StreamInterface)` should not guess `Content-Type`. +- `responses()->json()` should decode all responses, including error responses. +- v3 should not throw for HTTP error status codes by default. SDK authors opt into error behavior through `errors()`. +- Main author-facing classes should stay in the root namespace: `Api`, `Resource`, `Response`, `Entity`, and `ResponseEnvelope`. +- Internal/supporting classes can live in subnamespaces such as `Request`, `Context`, and `Builder`. +- Package exception classes can be decided as implementation needs emerge. +- Tests should use generic fake SDK fixtures, not downstream SDK names or classes. +- Keep PHP `>=8.1` for now. + +## Suggested v3 Authoring API + +Example SDK facade: + +```php +final class ExampleApi extends Api +{ + public function __construct(string $token) + { + parent::__construct(); + + $this + ->baseUrl('https://api.example.com') + ->auth()->bearer($token) + ->config(['timezone' => 'UTC']) + ->queryDefaults(['locale' => 'en']) + ->responses()->json(); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +Example resource: + +```php +final class UserResource extends Resource +{ + public function all(): UserCollection + { + return $this + ->query('active', true) + ->get('/users') + ->as(UserCollection::class); + } + + public function find(int $id): User + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); + } +} +``` + +Example custom response envelope: + +```php +final class FixtureResource extends Resource +{ + public function find(int $id): FixtureItem + { + return $this + ->get('/v3/football/fixtures/{id}', ['id' => $id]) + ->as(FixtureItem::class); + } +} +``` + +Example SDK-specific options: + +```php +trait IncludeTrait +{ + public function include(string ...$includes): static + { + return $this->query('include', implode(';', $includes)); + } +} +``` + +## Open Questions + +No blocking open questions remain for the first implementation phase. + +Future-phase questions should be answered when that phase starts, not before: + +- Exact hook method names and context details. +- Whether any public configuration getters are useful for testing or advanced extension. +- Whether `Method` remains as a tiny compatibility helper or is removed entirely. +- Whether `config()` ever supports nested keys. +- Exact internals of JSON, form, raw string, and stream body helpers. +- Whether a future `collect()` helper should return a small generic collection object. + +## First Implementation Slice + +1. Add fake SDK fixtures under tests. +2. Add `Resource`, `RequestOptions`, `Response`, and `Entity`. +3. Add protected/fluent resource creation and request execution to `Api`. +4. Prove one simple endpoint flow with a mock PSR client: + +```php +$user = $api->users()->find(1); +``` + +5. Add tests for path parameter replacement, fluent query options, query merge order, and entity mapping. + +Do not add collection mapping, custom envelopes, SDK config, entity context, JSON decoding, errors, hooks, body helpers, auth, plugins, cache, or logger in the first slice. Preserve momentum by getting the authoring experience right first. + +## Phase Discipline + +Implement v3 incrementally. The plan is intentionally broad because v3 must remain feature complete with v2, but each implementation phase should stay narrow. + +1. Prove fluent resource authoring for GET requests and entity mapping. +2. Add collection and custom envelope mapping. +3. Add SDK config and entity/response context. +4. Add JSON response decoding and error pipeline. +5. Add auth, plugins, cache, logger, and remaining PSR feature parity. +6. Update README and write `UPGRADE-3.0.md` once names and signatures are stable. + +Do not front-load advanced features before the simple SDK authoring path feels right. + +## Feature Parity Checklist + +Before tagging v3: + +- [ ] PSR-18 client support. +- [ ] PSR-17 request factory support. +- [ ] PSR-17 stream factory support. +- [ ] PSR-6 cache support. +- [ ] PSR-3 logger support. +- [ ] Authentication support. +- [ ] Plugin support. +- [ ] Request hooks. +- [ ] Response hooks. +- [ ] Response content transformation. +- [ ] Query defaults. +- [ ] Header defaults. +- [ ] Base URL handling. +- [ ] Path parameter replacement. +- [ ] JSON response decoding. +- [ ] Error mapping. +- [ ] Entity mapping. +- [ ] Collection mapping. +- [ ] Custom response envelope mapping. +- [ ] Entity context and SDK config access. +- [ ] SDK author test fixtures. +- [ ] README update. +- [ ] `UPGRADE-3.0.md`. +- [ ] OpenWeatherMap-style proof. +- [ ] SportMonks-style proof. From 2474adfdc44f418bf612ff461076db5c08a25eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 10:47:51 +0100 Subject: [PATCH 02/88] feat(v3): add fluent resource foundation --- src/Api.php | 156 ++++++++++++++++++++++++----- src/Builder/ResponseBuilder.php | 20 ++++ src/Entity.php | 8 ++ src/Request/RequestOptions.php | 52 ++++++++++ src/Resource.php | 55 ++++++++++ src/Response.php | 47 +++++++++ tests/Fixture/FakeApi.php | 28 ++++++ tests/Fixture/User.php | 31 ++++++ tests/Fixture/UserResource.php | 29 ++++++ tests/Integration/ResourceTest.php | 95 ++++++++++++++++++ 10 files changed, 495 insertions(+), 26 deletions(-) create mode 100644 src/Builder/ResponseBuilder.php create mode 100644 src/Entity.php create mode 100644 src/Request/RequestOptions.php create mode 100644 src/Resource.php create mode 100644 src/Response.php create mode 100644 tests/Fixture/FakeApi.php create mode 100644 tests/Fixture/User.php create mode 100644 tests/Fixture/UserResource.php create mode 100644 tests/Integration/ResourceTest.php diff --git a/src/Api.php b/src/Api.php index 54bbdba..0c7b041 100644 --- a/src/Api.php +++ b/src/Api.php @@ -12,12 +12,15 @@ use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener; use ProgrammatorDev\Api\Builder\LoggerBuilder; +use ProgrammatorDev\Api\Builder\ResponseBuilder; use ProgrammatorDev\Api\Event\PostRequestEvent; use ProgrammatorDev\Api\Event\PreRequestEvent; use ProgrammatorDev\Api\Event\ResponseContentsEvent; use ProgrammatorDev\Api\Helper\StringHelper; +use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface as ClientException; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -37,11 +40,14 @@ class Api private ?Authentication $authentication = null; + private ResponseBuilder $responseBuilder; + private EventDispatcher $eventDispatcher; public function __construct() { $this->clientBuilder ??= new ClientBuilder(); + $this->responseBuilder = new ResponseBuilder(); $this->eventDispatcher = new EventDispatcher(); } @@ -53,30 +59,10 @@ public function request( string $path, array $query = [], array $headers = [], - string|StreamInterface $body = null + string|StreamInterface|null $body = null ): mixed { - $this->configurePlugins(); - - if (!empty($this->queryDefaults)) { - $query = array_merge($this->queryDefaults, $query); - } - - if (!empty($this->headerDefaults)) { - $headers = array_merge($this->headerDefaults, $headers); - } - - $url = $this->buildUrl($path, $query); - $request = $this->createRequest($method, $url, $headers, $body); - - // pre request listener - $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); - - // request - $response = $this->clientBuilder->getClient()->sendRequest($request); - - // post request listener - $response = $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse(); + $response = $this->sendRequest($method, $path, $query, $headers, $body); // always rewind the body contents in case it was used in the PostRequestEvent // otherwise it would return an empty string @@ -87,6 +73,73 @@ public function request( return $this->eventDispatcher->dispatch(new ResponseContentsEvent($contents))->getContents(); } + /** + * @internal + * @throws ClientException + */ + public function send( + string $method, + string $path, + array $pathParams = [], + ?RequestOptions $options = null + ): Response + { + $options ??= new RequestOptions(); + $path = $this->buildPath($path, $pathParams); + + $response = $this->sendRequest( + method: $method, + path: $path, + query: $options->getQuery(), + headers: $options->getHeaders() + ); + + return new Response( + data: $this->getResponseData($response), + rawResponse: $response + ); + } + + /** + * @template T of Resource + * @param class-string $class + * @return T + */ + protected function resource(string $class): Resource + { + return new $class($this); + } + + protected function baseUrl(?string $baseUrl): static + { + $this->setBaseUrl($baseUrl); + + return $this; + } + + protected function queryDefaults(array $query): static + { + foreach ($query as $name => $value) { + $this->addQueryDefault($name, $value); + } + + return $this; + } + + protected function headerDefaults(array $headers): static + { + foreach ($headers as $name => $value) { + $this->addHeaderDefault($name, $value); + } + + return $this; + } + + protected function responses(): ResponseBuilder + { + return $this->responseBuilder; + } + private function configurePlugins(): void { // https://docs.php-http.org/en/latest/plugins/content-type.html @@ -268,7 +321,7 @@ public function buildPath(string $path, array $parameters): string foreach ($parameters as $parameter => $value) { $path = str_replace( sprintf('{%s}', $parameter), - $value, + rawurlencode((string) $value), $path ); } @@ -278,7 +331,8 @@ public function buildPath(string $path, array $parameters): string private function buildUrl(string $path, array $query = []): string { - $appendQuery = http_build_query($query); + $query = array_filter($query, static fn(mixed $value): bool => $value !== null); + $appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); if (StringHelper::isUrl($path)) { return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); @@ -292,7 +346,7 @@ private function createRequest( string $method, string $url, array $headers = [], - string|StreamInterface $body = null + string|StreamInterface|null $body = null ): RequestInterface { $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url); @@ -309,4 +363,54 @@ private function createRequest( return $request; } -} \ No newline at end of file + + /** + * @throws ClientException + */ + private function sendRequest( + string $method, + string $path, + array $query = [], + array $headers = [], + string|StreamInterface|null $body = null + ): ResponseInterface + { + $this->configurePlugins(); + + if (!empty($this->queryDefaults)) { + $query = array_merge($this->queryDefaults, $query); + } + + if (!empty($this->headerDefaults)) { + $headers = array_merge($this->headerDefaults, $headers); + } + + $url = $this->buildUrl($path, $query); + $request = $this->createRequest($method, $url, $headers, $body); + + // pre request listener + $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); + + // request + $response = $this->clientBuilder->getClient()->sendRequest($request); + + // post request listener + return $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse(); + } + + private function getResponseData(ResponseInterface $response): mixed + { + $response->getBody()->rewind(); + $contents = $response->getBody()->getContents(); + + if (!$this->responseBuilder->shouldDecodeJson()) { + return $contents; + } + + if ($contents === '') { + return null; + } + + return json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/src/Builder/ResponseBuilder.php b/src/Builder/ResponseBuilder.php new file mode 100644 index 0000000..6cdcf35 --- /dev/null +++ b/src/Builder/ResponseBuilder.php @@ -0,0 +1,20 @@ +decodeJson = true; + + return $this; + } + + public function shouldDecodeJson(): bool + { + return $this->decodeJson; + } +} diff --git a/src/Entity.php b/src/Entity.php new file mode 100644 index 0000000..736aef6 --- /dev/null +++ b/src/Entity.php @@ -0,0 +1,8 @@ +query; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function withQuery(string $name, mixed $value): self + { + return $this->withQueries([$name => $value]); + } + + public function withQueries(array $query): self + { + return new self( + query: array_merge($this->query, $this->filterNullValues($query)), + headers: $this->headers + ); + } + + public function withHeader(string $name, mixed $value): self + { + return $this->withHeaders([$name => $value]); + } + + public function withHeaders(array $headers): self + { + return new self( + query: $this->query, + headers: array_merge($this->headers, $headers) + ); + } + + private function filterNullValues(array $values): array + { + return array_filter($values, static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Resource.php b/src/Resource.php new file mode 100644 index 0000000..9859152 --- /dev/null +++ b/src/Resource.php @@ -0,0 +1,55 @@ +options = $options ?? new RequestOptions(); + } + + public function query(string $name, mixed $value): static + { + return $this->withOptions($this->options->withQuery($name, $value)); + } + + public function queries(array $query): static + { + return $this->withOptions($this->options->withQueries($query)); + } + + public function header(string $name, mixed $value): static + { + return $this->withOptions($this->options->withHeader($name, $value)); + } + + public function headers(array $headers): static + { + return $this->withOptions($this->options->withHeaders($headers)); + } + + protected function get(string $path, array $pathParams = [], array $query = []): Response + { + return $this->api->send( + method: Method::GET, + path: $path, + pathParams: $pathParams, + options: $this->options->withQueries($query) + ); + } + + private function withOptions(RequestOptions $options): static + { + $clone = clone $this; + $clone->options = $options; + + return $clone; + } +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..7cf0703 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,47 @@ +data; + } + + public function raw(): ResponseInterface + { + return $this->rawResponse; + } + + /** + * @template T of Entity + * @param class-string $class + * @return T + */ + public function entity(string $class, ?string $key = null): Entity + { + if (!is_subclass_of($class, Entity::class)) { + throw new \InvalidArgumentException(sprintf( + 'Entity class "%s" must implement %s.', + $class, + Entity::class + )); + } + + $data = $key === null ? $this->data : $this->data[$key]; + + if (!is_array($data)) { + throw new \UnexpectedValueException('Entity data must be an array.'); + } + + return $class::fromArray($data); + } +} diff --git a/tests/Fixture/FakeApi.php b/tests/Fixture/FakeApi.php new file mode 100644 index 0000000..16fdd79 --- /dev/null +++ b/tests/Fixture/FakeApi.php @@ -0,0 +1,28 @@ +setClientBuilder(new ClientBuilder($client)); + + $this + ->baseUrl('https://api.example.com') + ->queryDefaults(['locale' => 'en']) + ->responses() + ->json(); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} diff --git a/tests/Fixture/User.php b/tests/Fixture/User.php new file mode 100644 index 0000000..513551c --- /dev/null +++ b/tests/Fixture/User.php @@ -0,0 +1,31 @@ +id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php new file mode 100644 index 0000000..d02fafc --- /dev/null +++ b/tests/Fixture/UserResource.php @@ -0,0 +1,29 @@ +get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findFromEnvelope(int|string $id): User + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); + } + + public function findWithEndpointLocale(int|string $id, string $locale): User + { + return $this + ->get('/users/{id}', ['id' => $id], ['locale' => $locale]) + ->entity(User::class); + } +} diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php new file mode 100644 index 0000000..3576682 --- /dev/null +++ b/tests/Integration/ResourceTest.php @@ -0,0 +1,95 @@ +client = new Client(); + $this->api = new FakeApi($this->client); + } + + public function testResourceGetMapsEntity(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $user = $this->api->users()->find(1); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame(1, $user->getId()); + $this->assertSame('John', $user->getName()); + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); + } + + public function testResourcePathParametersAreEncoded(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->find('john/doe'); + + $this->assertSame('https://api.example.com/users/john%2Fdoe?locale=en', (string) $this->client->getLastRequest()->getUri()); + } + + public function testResourceOptionsAreImmutable(): 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(); + + $users->query('active', true)->find(1); + $users->find(2); + + $requests = $this->client->getRequests(); + + $this->assertSame('https://api.example.com/users/1?locale=en&active=1', (string) $requests[0]->getUri()); + $this->assertSame('https://api.example.com/users/2?locale=en', (string) $requests[1]->getUri()); + } + + public function testEndpointQueryOverridesResourceAndGlobalDefaults(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->users() + ->query('locale', 'fr') + ->findWithEndpointLocale(1, 'pt'); + + $this->assertSame('https://api.example.com/users/1?locale=pt', (string) $this->client->getLastRequest()->getUri()); + } + + public function testNullQueryValuesAreOmitted(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->users() + ->query('empty', null) + ->find(1); + + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); + } + + public function testEntityCanBeMappedFromResponseKey(): void + { + $this->client->addResponse(new Response(body: '{"data":{"id":1,"name":"John"}}')); + + $user = $this->api->users()->findFromEnvelope(1); + + $this->assertSame(1, $user->getId()); + $this->assertSame('John', $user->getName()); + } +} From a1be9e62a13e5baa865fb2df938b7fb761762224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 11:02:40 +0100 Subject: [PATCH 03/88] feat(v3): add response mapping helpers --- src/Response.php | 86 ++++++++++++- src/ResponseEnvelope.php | 8 ++ tests/Fixture/UserEnvelope.php | 32 +++++ tests/Fixture/UserResource.php | 14 +++ tests/Integration/ResourceTest.php | 26 ++++ tests/Unit/ResponseTest.php | 186 +++++++++++++++++++++++++++++ 6 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 src/ResponseEnvelope.php create mode 100644 tests/Fixture/UserEnvelope.php create mode 100644 tests/Unit/ResponseTest.php diff --git a/src/Response.php b/src/Response.php index 7cf0703..cf6d1de 100644 --- a/src/Response.php +++ b/src/Response.php @@ -27,6 +27,74 @@ public function raw(): ResponseInterface * @return T */ public function entity(string $class, ?string $key = null): Entity + { + $this->assertEntityClass($class); + + $data = $this->getData($key); + + if (!is_array($data)) { + throw new \UnexpectedValueException('Entity data must be an array.'); + } + + return $class::fromArray($data); + } + + /** + * @template T of Entity + * @param class-string $class + * @return T[] + */ + public function collection(string $class, ?string $key = null): array + { + $this->assertEntityClass($class); + + $items = $this->getData($key); + + if (!is_array($items)) { + throw new \UnexpectedValueException('Collection data must be an array.'); + } + + return array_map(static function (mixed $item) use ($class): Entity { + if (!is_array($item)) { + throw new \UnexpectedValueException('Collection item data must be an array.'); + } + + return $class::fromArray($item); + }, $items); + } + + /** + * @template T of ResponseEnvelope + * @param class-string $class + * @return T + */ + public function as(string $class): ResponseEnvelope + { + $this->assertResponseEnvelopeClass($class); + + return $class::fromResponse($this); + } + + private function getData(?string $key): mixed + { + if ($key === null) { + return $this->data; + } + + if (!is_array($this->data) || !array_key_exists($key, $this->data)) { + throw new \UnexpectedValueException(sprintf( + 'Response data key "%s" does not exist.', + $key + )); + } + + return $this->data[$key]; + } + + /** + * @param class-string $class + */ + private function assertEntityClass(string $class): void { if (!is_subclass_of($class, Entity::class)) { throw new \InvalidArgumentException(sprintf( @@ -35,13 +103,19 @@ public function entity(string $class, ?string $key = null): Entity Entity::class )); } + } - $data = $key === null ? $this->data : $this->data[$key]; - - if (!is_array($data)) { - throw new \UnexpectedValueException('Entity data must be an array.'); + /** + * @param class-string $class + */ + private function assertResponseEnvelopeClass(string $class): void + { + if (!is_subclass_of($class, ResponseEnvelope::class)) { + throw new \InvalidArgumentException(sprintf( + 'Response envelope class "%s" must implement %s.', + $class, + ResponseEnvelope::class + )); } - - return $class::fromArray($data); } } diff --git a/src/ResponseEnvelope.php b/src/ResponseEnvelope.php new file mode 100644 index 0000000..88fd078 --- /dev/null +++ b/src/ResponseEnvelope.php @@ -0,0 +1,8 @@ +entity(User::class, key: 'data'), + statusCode: $response->raw()->getStatusCode() + ); + } + + public function getUser(): User + { + return $this->user; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index d02fafc..c3105d7 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -6,6 +6,13 @@ class UserResource extends Resource { + public function all(): array + { + return $this + ->get('/users') + ->collection(User::class, key: 'data'); + } + public function find(int|string $id): User { return $this @@ -20,6 +27,13 @@ public function findFromEnvelope(int|string $id): User ->entity(User::class, key: 'data'); } + public function findEnvelope(int|string $id): UserEnvelope + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->as(UserEnvelope::class); + } + public function findWithEndpointLocale(int|string $id, string $locale): User { return $this diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 3576682..b26e896 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -7,6 +7,7 @@ use ProgrammatorDev\Api\Test\AbstractTestCase; use ProgrammatorDev\Api\Test\Fixture\FakeApi; use ProgrammatorDev\Api\Test\Fixture\User; +use ProgrammatorDev\Api\Test\Fixture\UserEnvelope; class ResourceTest extends AbstractTestCase { @@ -92,4 +93,29 @@ public function testEntityCanBeMappedFromResponseKey(): void $this->assertSame(1, $user->getId()); $this->assertSame('John', $user->getName()); } + + public function testCollectionCanBeMappedFromResponseKey(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"},{"id":2,"name":"Jane"}]}')); + + $users = $this->api->users()->all(); + + $this->assertContainsOnlyInstancesOf(User::class, $users); + $this->assertSame(1, $users[0]->getId()); + $this->assertSame('John', $users[0]->getName()); + $this->assertSame(2, $users[1]->getId()); + $this->assertSame('Jane', $users[1]->getName()); + } + + public function testResponseCanBeMappedToEnvelope(): void + { + $this->client->addResponse(new Response(status: 202, body: '{"data":{"id":1,"name":"John"}}')); + + $envelope = $this->api->users()->findEnvelope(1); + + $this->assertInstanceOf(UserEnvelope::class, $envelope); + $this->assertSame(202, $envelope->getStatusCode()); + $this->assertSame(1, $envelope->getUser()->getId()); + $this->assertSame('John', $envelope->getUser()->getName()); + } } diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php new file mode 100644 index 0000000..4070d20 --- /dev/null +++ b/tests/Unit/ResponseTest.php @@ -0,0 +1,186 @@ + 1, 'name' => 'John']; + $response = new Response($data, new PsrResponse()); + + $this->assertSame($data, $response->data()); + } + + public function testRawReturnsPsrResponse(): void + { + $rawResponse = new PsrResponse(status: 202); + $response = new Response(['id' => 1], $rawResponse); + + $this->assertSame($rawResponse, $response->raw()); + } + + public function testEntityCanBeMappedFromRootData(): void + { + $response = new Response(['id' => 1, 'name' => 'John'], new PsrResponse()); + + $user = $response->entity(User::class); + + $this->assertSame(1, $user->getId()); + $this->assertSame('John', $user->getName()); + } + + public function testCollectionCanBeMappedFromRootData(): void + { + $response = new Response([ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], new PsrResponse()); + + $users = $response->collection(User::class); + + $this->assertContainsOnlyInstancesOf(User::class, $users); + $this->assertSame(1, $users[0]->getId()); + $this->assertSame('John', $users[0]->getName()); + $this->assertSame(2, $users[1]->getId()); + $this->assertSame('Jane', $users[1]->getName()); + } + + public function testCollectionCanMapEmptyData(): void + { + $response = new Response([], new PsrResponse()); + + $this->assertSame([], $response->collection(User::class)); + } + + public function testCollectionPreservesKeys(): void + { + $response = new Response([ + 'admin' => ['id' => 1, 'name' => 'John'], + 'user' => ['id' => 2, 'name' => 'Jane'], + ], new PsrResponse()); + + $users = $response->collection(User::class); + + $this->assertSame(['admin', 'user'], array_keys($users)); + $this->assertSame('John', $users['admin']->getName()); + $this->assertSame('Jane', $users['user']->getName()); + } + + public function testEntityRejectsClassThatDoesNotImplementEntity(): void + { + $response = new Response(['id' => 1], new PsrResponse()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + $response->entity(\stdClass::class); + } + + public function testCollectionRejectsClassThatDoesNotImplementEntity(): void + { + $response = new Response([['id' => 1]], new PsrResponse()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + $response->collection(\stdClass::class); + } + + public function testEnvelopeRejectsClassThatDoesNotImplementResponseEnvelope(): void + { + $response = new Response(['data' => ['id' => 1]], new PsrResponse()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + $response->as(\stdClass::class); + } + + public function testEntityRejectsNonArrayData(): void + { + $response = new Response('not-array', new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Entity data must be an array.'); + + $response->entity(User::class); + } + + public function testEntityRejectsNonArrayDataFromKey(): void + { + $response = new Response(['data' => 'not-array'], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Entity data must be an array.'); + + $response->entity(User::class, key: 'data'); + } + + public function testEntityRejectsMissingDataKey(): void + { + $response = new Response(['id' => 1], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Response data key "data" does not exist.'); + + $response->entity(User::class, key: 'data'); + } + + public function testCollectionRejectsNonArrayData(): void + { + $response = new Response('not-array', new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Collection data must be an array.'); + + $response->collection(User::class); + } + + public function testCollectionRejectsNonArrayDataFromKey(): void + { + $response = new Response(['data' => 'not-array'], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Collection data must be an array.'); + + $response->collection(User::class, key: 'data'); + } + + public function testCollectionRejectsMissingDataKey(): void + { + $response = new Response(['items' => []], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Response data key "data" does not exist.'); + + $response->collection(User::class, key: 'data'); + } + + public function testCollectionRejectsNonArrayItems(): void + { + $response = new Response(['data' => ['not-array-item']], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Collection item data must be an array.'); + + $response->collection(User::class, key: 'data'); + } + + public function testEnvelopeCanBeMapped(): void + { + $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse(status: 202)); + + $envelope = $response->as(UserEnvelope::class); + + $this->assertSame(202, $envelope->getStatusCode()); + $this->assertSame(1, $envelope->getUser()->getId()); + $this->assertSame('John', $envelope->getUser()->getName()); + } +} From d491215649f24ebec6b76406589d97559b271f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 11:10:22 +0100 Subject: [PATCH 04/88] feat(v3): add resource verb helpers --- src/Resource.php | 37 +++++++++++++++++++++++++++++- tests/Fixture/UserResource.php | 13 +++++++++++ tests/Integration/ResourceTest.php | 25 ++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/Resource.php b/src/Resource.php index 9859152..7c5f72c 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -36,9 +36,44 @@ public function headers(array $headers): static } protected function get(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::GET, $path, $pathParams, $query); + } + + protected function post(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::POST, $path, $pathParams, $query); + } + + protected function put(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::PUT, $path, $pathParams, $query); + } + + protected function patch(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::PATCH, $path, $pathParams, $query); + } + + protected function delete(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::DELETE, $path, $pathParams, $query); + } + + protected function head(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::HEAD, $path, $pathParams, $query); + } + + protected function options(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::OPTIONS, $path, $pathParams, $query); + } + + protected function send(string $method, string $path, array $pathParams = [], array $query = []): Response { return $this->api->send( - method: Method::GET, + method: $method, path: $path, pathParams: $pathParams, options: $this->options->withQueries($query) diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index c3105d7..0d5a151 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -6,6 +6,19 @@ class UserResource extends Resource { + public function sendWithVerb(string $verb): void + { + match ($verb) { + 'GET' => $this->get('/users'), + 'POST' => $this->post('/users'), + 'PUT' => $this->put('/users/{id}', ['id' => 1]), + 'PATCH' => $this->patch('/users/{id}', ['id' => 1]), + 'DELETE' => $this->delete('/users/{id}', ['id' => 1]), + 'HEAD' => $this->head('/users'), + 'OPTIONS' => $this->options('/users'), + }; + } + public function all(): array { return $this diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index b26e896..a0beded 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -35,6 +35,18 @@ public function testResourceGetMapsEntity(): void $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); } + /** + * @dataProvider resourceVerbProvider + */ + public function testResourceCanSendHttpVerbs(string $verb): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->sendWithVerb($verb); + + $this->assertSame($verb, $this->client->getLastRequest()->getMethod()); + } + public function testResourcePathParametersAreEncoded(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); @@ -118,4 +130,17 @@ public function testResponseCanBeMappedToEnvelope(): void $this->assertSame(1, $envelope->getUser()->getId()); $this->assertSame('John', $envelope->getUser()->getName()); } + + public static function resourceVerbProvider(): array + { + return [ + 'get' => ['GET'], + 'post' => ['POST'], + 'put' => ['PUT'], + 'patch' => ['PATCH'], + 'delete' => ['DELETE'], + 'head' => ['HEAD'], + 'options' => ['OPTIONS'], + ]; + } } From b75fc2082f544538435e7196b422d25254c72738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 11:14:39 +0100 Subject: [PATCH 05/88] feat(v3): add resource body helpers --- src/Api.php | 3 +- src/Request/RequestOptions.php | 25 ++++++++++-- src/Resource.php | 28 ++++++++++++++ tests/Fixture/UserResource.php | 21 ++++++++++ tests/Integration/ResourceTest.php | 61 ++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/Api.php b/src/Api.php index 0c7b041..c9771e9 100644 --- a/src/Api.php +++ b/src/Api.php @@ -91,7 +91,8 @@ public function send( method: $method, path: $path, query: $options->getQuery(), - headers: $options->getHeaders() + headers: $options->getHeaders(), + body: $options->getBody() ); return new Response( diff --git a/src/Request/RequestOptions.php b/src/Request/RequestOptions.php index 01fed39..ee1e1fa 100644 --- a/src/Request/RequestOptions.php +++ b/src/Request/RequestOptions.php @@ -2,11 +2,14 @@ namespace ProgrammatorDev\Api\Request; +use Psr\Http\Message\StreamInterface; + class RequestOptions { public function __construct( private readonly array $query = [], - private readonly array $headers = [] + private readonly array $headers = [], + private readonly string|StreamInterface|null $body = null ) {} public function getQuery(): array @@ -19,6 +22,11 @@ public function getHeaders(): array return $this->headers; } + public function getBody(): string|StreamInterface|null + { + return $this->body; + } + public function withQuery(string $name, mixed $value): self { return $this->withQueries([$name => $value]); @@ -28,7 +36,8 @@ public function withQueries(array $query): self { return new self( query: array_merge($this->query, $this->filterNullValues($query)), - headers: $this->headers + headers: $this->headers, + body: $this->body ); } @@ -41,7 +50,17 @@ public function withHeaders(array $headers): self { return new self( query: $this->query, - headers: array_merge($this->headers, $headers) + headers: array_merge($this->headers, $headers), + body: $this->body + ); + } + + public function withBody(string|StreamInterface|null $body): self + { + return new self( + query: $this->query, + headers: $this->headers, + body: $body ); } diff --git a/src/Resource.php b/src/Resource.php index 7c5f72c..37fe417 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\Api; use ProgrammatorDev\Api\Request\RequestOptions; +use Psr\Http\Message\StreamInterface; abstract class Resource { @@ -35,6 +36,33 @@ public function headers(array $headers): static return $this->withOptions($this->options->withHeaders($headers)); } + public function json(array $data): static + { + return $this + ->header('Content-Type', 'application/json') + ->body(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function form(array $data): static + { + return $this + ->header('Content-Type', 'application/x-www-form-urlencoded') + ->body(http_build_query($data)); + } + + public function body(mixed $body): static + { + if (is_array($body)) { + throw new \InvalidArgumentException('Use json() or form() to send array request data.'); + } + + if (!$body instanceof StreamInterface && !is_string($body) && $body !== null) { + throw new \InvalidArgumentException('Request body must be a string, stream, or null.'); + } + + return $this->withOptions($this->options->withBody($body)); + } + protected function get(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::GET, $path, $pathParams, $query); diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 0d5a151..5079a34 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\Api\Test\Fixture; use ProgrammatorDev\Api\Resource; +use Psr\Http\Message\StreamInterface; class UserResource extends Resource { @@ -19,6 +20,26 @@ public function sendWithVerb(string $verb): void }; } + public function createWithJson(array $data): void + { + $this->json($data)->post('/users'); + } + + public function createWithForm(array $data): void + { + $this->form($data)->post('/users'); + } + + public function createWithBody(string|StreamInterface|null $body): void + { + $this->body($body)->post('/users'); + } + + public function createWithInvalidBody(mixed $body): void + { + $this->body($body)->post('/users'); + } + public function all(): array { return $this diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index a0beded..233b4ca 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -4,6 +4,7 @@ use Http\Mock\Client; use Nyholm\Psr7\Response; +use Nyholm\Psr7\Stream; use ProgrammatorDev\Api\Test\AbstractTestCase; use ProgrammatorDev\Api\Test\Fixture\FakeApi; use ProgrammatorDev\Api\Test\Fixture\User; @@ -47,6 +48,66 @@ public function testResourceCanSendHttpVerbs(string $verb): void $this->assertSame($verb, $this->client->getLastRequest()->getMethod()); } + public function testResourceCanSendJsonBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->createWithJson(['name' => 'John']); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame('{"name":"John"}', (string) $request->getBody()); + } + + public function testResourceCanSendFormBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->createWithForm(['name' => 'John Doe']); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $this->assertSame('name=John+Doe', (string) $request->getBody()); + } + + public function testResourceCanSendRawStringBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->createWithBody('raw-body'); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('raw-body', (string) $request->getBody()); + } + + public function testResourceCanSendStreamBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $stream = Stream::create('stream-body'); + + $this->api->users()->createWithBody($stream); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('stream-body', (string) $request->getBody()); + } + + public function testResourceBodyRejectsArrayData(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Use json() or form() to send array request data.'); + + $this->api->users()->createWithInvalidBody(['name' => 'John']); + } + public function testResourcePathParametersAreEncoded(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); From e2db7001243f61c394b9acebcae2a0e4286d0465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 11:36:18 +0100 Subject: [PATCH 06/88] docs(v3): add authoring guides --- docs/getting-started.md | 165 +++++++++++++++++++++++++ docs/index.md | 26 ++++ docs/resource-authoring.md | 232 +++++++++++++++++++++++++++++++++++ docs/v3-architecture-plan.md | 41 ++++--- 4 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/resource-authoring.md diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d67e605 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,165 @@ +# Getting Started + +These examples describe the work-in-progress `v3.0` authoring API. + +## Install + +```bash +composer require programmatordev/php-api-sdk +``` + +You also need compatible PSR-18 and PSR-17 implementations. The package uses PHP-HTTP discovery, so SDK packages can choose the implementations they want to require or suggest. + +## Create An API Class + +The API class is the SDK facade. It configures shared options and exposes purpose-built resources. + +```php +use ProgrammatorDev\Api\Api; + +final class ExampleApi extends Api +{ + public function __construct(string $apiKey) + { + parent::__construct(); + + $this + ->baseUrl('https://api.example.com') + ->queryDefaults(['api_key' => $apiKey, 'locale' => 'en']) + ->headerDefaults(['Accept' => 'application/json']); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +The final SDK user works with `users()`, not raw request execution: + +```php +$user = $api->users()->find(1); +``` + +## Create An Entity + +Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `Entity`. + +```php +use ProgrammatorDev\Api\Entity; + +final class User implements Entity +{ + public function __construct( + public readonly int $id, + public readonly string $name, + ) {} + + public static function fromArray(array $data): static + { + return new self( + id: $data['id'], + name: $data['name'], + ); + } +} +``` + +## Create A Resource + +Resources group endpoint methods. Use protected HTTP helpers and response mapping helpers to keep the endpoint code compact. + +```php +use ProgrammatorDev\Api\Resource; + +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + /** + * @return User[] + */ + public function all(): array + { + return $this + ->get('/users') + ->collection(User::class, key: 'data'); + } + + public function create(string $name): User + { + return $this + ->json(['name' => $name]) + ->post('/users') + ->entity(User::class, key: 'data'); + } +} +``` + +Path parameters are passed as the second argument to the HTTP helper: + +```php +$this->get('/users/{id}', ['id' => $id]); +``` + +Endpoint-specific query parameters can be passed as the third argument: + +```php +$this->get('/users/{id}', ['id' => $id], ['locale' => 'pt']); +``` + +Reusable query and header options are fluent and immutable: + +```php +$activeUsers = $api + ->users() + ->query('active', true) + ->all(); +``` + +## Map Enveloped Responses + +If an API returns metadata, pagination, or any custom envelope, create a response envelope class. + +```php +use ProgrammatorDev\Api\Response; +use ProgrammatorDev\Api\ResponseEnvelope; + +final class UserResponse implements ResponseEnvelope +{ + public function __construct( + public readonly User $user, + public readonly int $statusCode, + ) {} + + public static function fromResponse(Response $response): static + { + return new self( + user: $response->entity(User::class, key: 'data'), + statusCode: $response->raw()->getStatusCode(), + ); + } +} +``` + +Then return it from the resource: + +```php +public function findWithMeta(int $id): UserResponse +{ + return $this + ->get('/users/{id}', ['id' => $id]) + ->as(UserResponse::class); +} +``` + +## Next Steps + +- Read [Resource Authoring](resource-authoring.md) for the full resource API. +- Read [Architecture Plan](v3-architecture-plan.md) for current decisions and remaining v3 work. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d16e5c4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,26 @@ +# Documentation + +These docs describe how to create API SDKs with this package. + +These docs describe the upcoming `v3.0` API. The stable `v2.x` docs remain in the root `README.md` until v3 is released. + +## Requirements + +- PHP `>=8.1` +- A PSR-18 HTTP client implementation +- PSR-17 request and stream factory implementations + +The package can discover compatible HTTP clients and factories through PHP-HTTP discovery when implementations are installed. + +## Installation + +```bash +composer require programmatordev/php-api-sdk +``` + +SDK packages should also require or suggest concrete PSR-18 and PSR-17 implementations suitable for their users. + +## Guides + +- [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. +- [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md new file mode 100644 index 0000000..47487c4 --- /dev/null +++ b/docs/resource-authoring.md @@ -0,0 +1,232 @@ +# Resource Authoring + +Resources are the main authoring surface for SDK developers. They group endpoints and hide low-level request execution from the final SDK user. + +The typical endpoint method should stay compact: + +```php +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } +} +``` + +SDK users call purpose-built methods: + +```php +$user = $api->users()->find(1); +``` + +## Resources From The API + +Concrete SDKs should expose resources through methods on their API class: + +```php +final class ExampleApi extends Api +{ + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +`Api::resource()` creates a fresh resource instance. Resource option modifiers are immutable, so fluent customizations do not leak into later calls. + +## HTTP Methods + +Resources expose protected HTTP helpers: + +```php +$this->get('/users'); +$this->post('/users'); +$this->put('/users/{id}', ['id' => $id]); +$this->patch('/users/{id}', ['id' => $id]); +$this->delete('/users/{id}', ['id' => $id]); +$this->head('/users'); +$this->options('/users'); +``` + +Each helper executes the request immediately and returns a `Response` wrapper. + +Endpoint-specific query parameters can be passed as the third argument: + +```php +return $this + ->get('/users/{id}', ['id' => $id], ['locale' => 'pt']) + ->entity(User::class); +``` + +Path parameters are encoded with `rawurlencode`. + +## Query And Headers + +Use resource modifiers for reusable request options: + +```php +return $this + ->query('active', true) + ->header('X-Tenant', $tenant) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +Multiple values can be set with `queries()` and `headers()`: + +```php +return $this + ->queries(['active' => true, 'locale' => 'pt']) + ->headers(['X-Tenant' => $tenant]) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +Query merge order is: + +```text +API defaults < resource options < endpoint-specific options +``` + +Null query values are omitted. Boolean and array query values use PHP's standard `http_build_query` behavior. + +## Request Bodies + +Use explicit body helpers for structured request data: + +```php +return $this + ->json(['name' => 'John']) + ->post('/users') + ->entity(User::class); +``` + +`json()` encodes the array as JSON and sets `Content-Type: application/json`. + +```php +return $this + ->form(['name' => 'John Doe']) + ->post('/users') + ->entity(User::class); +``` + +`form()` encodes the array with `http_build_query` and sets `Content-Type: application/x-www-form-urlencoded`. + +Use `body()` for raw string or PSR-7 stream bodies: + +```php +return $this + ->body($stream) + ->post('/uploads') + ->raw(); +``` + +`body()` does not guess the content type. Passing an array to `body()` throws; use `json()` or `form()` instead. + +## Response Mapping + +Use `entity()` when the endpoint returns one typed object: + +```php +return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +``` + +Entities must implement `Entity`: + +```php +final class User implements Entity +{ + public static function fromArray(array $data): static + { + return new self( + id: $data['id'], + name: $data['name'], + ); + } +} +``` + +Use the optional `key` argument when the object is nested inside an envelope: + +```php +return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); +``` + +Use `collection()` when the endpoint returns a list: + +```php +return $this + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +`collection()` returns a plain array of entities. + +Use `as()` when the response carries metadata, pagination, or any API-specific envelope: + +```php +return $this + ->get('/users/{id}', ['id' => $id]) + ->as(UserResponse::class); +``` + +Envelope classes must implement `ResponseEnvelope`: + +```php +final class UserResponse implements ResponseEnvelope +{ + public function __construct( + private readonly User $user, + private readonly int $statusCode, + ) {} + + public static function fromResponse(Response $response): static + { + return new self( + user: $response->entity(User::class, key: 'data'), + statusCode: $response->raw()->getStatusCode(), + ); + } +} +``` + +## API-Specific Traits + +Keep API-specific vocabulary out of the base package. Add it in SDK packages through traits or API-specific base resources. + +For example, an SDK can add includes without making the generic package know what an include is: + +```php +trait HasIncludes +{ + public function include(string ...$includes): static + { + return $this->query('include', implode(';', $includes)); + } +} +``` + +Then use it in that SDK's resources: + +```php +final class FixtureResource extends Resource +{ + use HasIncludes; + + public function find(int $id): FixtureResponse + { + return $this + ->include('participants', 'league') + ->get('/fixtures/{id}', ['id' => $id]) + ->as(FixtureResponse::class); + } +} +``` diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 6512cb6..e2e1183 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -339,7 +339,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - SDK-wide options should be stored in a generic config bag. - SDK config should be available through context objects, not by injecting `Api` into entities. - `Response::entity()` and `Response::collection()` should require classes that implement `Entity`. -- `Response::as()` should support API-specific response envelope classes such as SportMonks item and collection responses. +- `Response::as()` should support API-specific response envelope classes such as item, collection, metadata, and pagination responses. - `Response::as()` should require a `ResponseEnvelope` contract with `fromResponse(Response $response)`. - `Response::collection()` should return a plain array by default. - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. @@ -471,7 +471,6 @@ Future-phase questions should be answered when that phase starts, not before: - Whether any public configuration getters are useful for testing or advanced extension. - Whether `Method` remains as a tiny compatibility helper or is removed entirely. - Whether `config()` ever supports nested keys. -- Exact internals of JSON, form, raw string, and stream body helpers. - Whether a future `collect()` helper should return a small generic collection object. ## First Implementation Slice @@ -495,10 +494,12 @@ Implement v3 incrementally. The plan is intentionally broad because v3 must rema 1. Prove fluent resource authoring for GET requests and entity mapping. 2. Add collection and custom envelope mapping. -3. Add SDK config and entity/response context. -4. Add JSON response decoding and error pipeline. -5. Add auth, plugins, cache, logger, and remaining PSR feature parity. -6. Update README and write `UPGRADE-3.0.md` once names and signatures are stable. +3. Add resource HTTP verb helpers. +4. Add resource body helpers. +5. Add SDK config and entity/response context. +6. Add JSON response decoding and error pipeline. +7. Add auth, plugins, cache, logger, and remaining PSR feature parity. +8. Update README and write `UPGRADE-3.0.md` once names and signatures are stable. Do not front-load advanced features before the simple SDK authoring path feels right. @@ -506,9 +507,9 @@ Do not front-load advanced features before the simple SDK authoring path feels r Before tagging v3: -- [ ] PSR-18 client support. -- [ ] PSR-17 request factory support. -- [ ] PSR-17 stream factory support. +- [x] PSR-18 client support. +- [x] PSR-17 request factory support. +- [x] PSR-17 stream factory support. - [ ] PSR-6 cache support. - [ ] PSR-3 logger support. - [ ] Authentication support. @@ -516,18 +517,20 @@ Before tagging v3: - [ ] Request hooks. - [ ] Response hooks. - [ ] Response content transformation. -- [ ] Query defaults. -- [ ] Header defaults. -- [ ] Base URL handling. -- [ ] Path parameter replacement. +- [x] Query defaults. +- [x] Header defaults. +- [x] Base URL handling. +- [x] Path parameter replacement. +- [x] Resource HTTP verb helpers. +- [x] Resource body helpers. - [ ] JSON response decoding. - [ ] Error mapping. -- [ ] Entity mapping. -- [ ] Collection mapping. -- [ ] Custom response envelope mapping. +- [x] Entity mapping. +- [x] Collection mapping. +- [x] Custom response envelope mapping. - [ ] Entity context and SDK config access. -- [ ] SDK author test fixtures. +- [x] SDK author test fixtures. - [ ] README update. - [ ] `UPGRADE-3.0.md`. -- [ ] OpenWeatherMap-style proof. -- [ ] SportMonks-style proof. +- [ ] Simple API proof. +- [ ] Complex API proof. From 55518631d3280eef6ab065d3852f1eba04ee4065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 11:45:47 +0100 Subject: [PATCH 07/88] feat(v3): add SDK config bag --- src/Api.php | 12 +++++++++ src/Config.php | 45 ++++++++++++++++++++++++++++++++ tests/Integration/ApiTest.php | 36 +++++++++++++++++++++++++- tests/Unit/ConfigTest.php | 48 +++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/Config.php create mode 100644 tests/Unit/ConfigTest.php diff --git a/src/Api.php b/src/Api.php index c9771e9..a2e06b6 100644 --- a/src/Api.php +++ b/src/Api.php @@ -32,6 +32,8 @@ class Api private array $headerDefaults = []; + private Config $config; + private ClientBuilder $clientBuilder; private ?CacheBuilder $cacheBuilder = null; @@ -46,6 +48,7 @@ class Api public function __construct() { + $this->config = new Config(); $this->clientBuilder ??= new ClientBuilder(); $this->responseBuilder = new ResponseBuilder(); $this->eventDispatcher = new EventDispatcher(); @@ -141,6 +144,15 @@ protected function responses(): ResponseBuilder return $this->responseBuilder; } + protected function config(?array $values = null): Config + { + if ($values !== null) { + $this->config->merge($values); + } + + return $this->config; + } + private function configurePlugins(): void { // https://docs.php-http.org/en/latest/plugins/content-type.html diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..2a22b45 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,45 @@ +values; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function get(string $key, mixed $default = null): mixed + { + if (!$this->has($key)) { + return $default; + } + + return $this->values[$key]; + } + + public function set(string $key, mixed $value): self + { + $this->values[$key] = $value; + + return $this; + } + + public function merge(array $values): self + { + foreach ($values as $key => $value) { + $this->set($key, $value); + } + + return $this; + } +} diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 5d8bcaf..07e9352 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -88,6 +88,40 @@ public function testHeaderDefaults() $this->assertNull($this->api->getHeaderDefault('X-Test')); } + public function testConfigCanBeSetAndReadBySdkApi(): void + { + $api = new class extends Api { + public function setOptions(array $options): self + { + $this->config($options); + + return $this; + } + + public function option(string $key, mixed $default = null): mixed + { + return $this->config()->get($key, $default); + } + + public function options(): array + { + return $this->config()->all(); + } + }; + + $api + ->setOptions(['timezone' => 'UTC']) + ->setOptions(['units' => 'metric']); + + $this->assertSame('UTC', $api->option('timezone')); + $this->assertSame('metric', $api->option('units')); + $this->assertSame('en', $api->option('locale', 'en')); + $this->assertSame([ + 'timezone' => 'UTC', + 'units' => 'metric', + ], $api->options()); + } + public function testCache() { $this->assertNull($this->api->getCacheBuilder()); @@ -238,4 +272,4 @@ public function testBuildPath() $this->assertSame('/path/with/multiple/parameters', $path); } -} \ No newline at end of file +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php new file mode 100644 index 0000000..c6f8d19 --- /dev/null +++ b/tests/Unit/ConfigTest.php @@ -0,0 +1,48 @@ + 'UTC']); + + $this->assertTrue($config->has('timezone')); + $this->assertSame('UTC', $config->get('timezone')); + $this->assertSame(['timezone' => 'UTC'], $config->all()); + } + + public function testConfigReturnsDefaultForMissingValue(): void + { + $config = new Config(); + + $this->assertFalse($config->has('timezone')); + $this->assertSame('Europe/Lisbon', $config->get('timezone', 'Europe/Lisbon')); + } + + public function testConfigCanStoreNullValues(): void + { + $config = new Config(['timezone' => null]); + + $this->assertTrue($config->has('timezone')); + $this->assertNull($config->get('timezone', 'Europe/Lisbon')); + } + + public function testConfigCanBeUpdated(): void + { + $config = new Config(['timezone' => 'UTC']); + + $config + ->set('timezone', 'Europe/Lisbon') + ->merge(['units' => 'metric']); + + $this->assertSame([ + 'timezone' => 'Europe/Lisbon', + 'units' => 'metric', + ], $config->all()); + } +} From 615f4cccb53d74c32f28e5301ac12c1ff1cb8de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 11:49:02 +0100 Subject: [PATCH 08/88] test(v3): move internal test support --- src/Test/AbstractTestCase.php | 10 - src/Test/MockResponse.php | 8 - tests/Integration/ApiTest.php | 234 +---------------------- tests/Integration/ResourceTest.php | 2 +- tests/Support/AbstractTestCase.php | 9 + tests/Unit/Builder/CacheBuilderTest.php | 2 +- tests/Unit/Builder/ClientBuilderTest.php | 2 +- tests/Unit/Builder/LoggerBuilderTest.php | 2 +- tests/Unit/ConfigTest.php | 2 +- tests/Unit/Helper/StringHelperTest.php | 2 +- tests/Unit/ResponseTest.php | 2 +- 11 files changed, 17 insertions(+), 258 deletions(-) delete mode 100644 src/Test/AbstractTestCase.php delete mode 100644 src/Test/MockResponse.php create mode 100644 tests/Support/AbstractTestCase.php diff --git a/src/Test/AbstractTestCase.php b/src/Test/AbstractTestCase.php deleted file mode 100644 index 497ef67..0000000 --- a/src/Test/AbstractTestCase.php +++ /dev/null @@ -1,10 +0,0 @@ -api = new class extends Api {}; - - // set mock client - $this->mockClient = new Client(); - $this->api->setClientBuilder(new ClientBuilder($this->mockClient)); - } - - public function testRequest() - { - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - - $response = $this->api->request( - method: 'GET', - path: '/path' - ); - - $this->assertSame(MockResponse::SUCCESS, $response); - } - - public function testMultipleRequests() - { - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - - $this->api->request(method: 'GET', path: '/path-1'); - $this->api->request(method: 'GET', path: '/path-2'); - - $this->assertTrue(true); - } - - public function testBaseUrl() - { - $this->assertNull($this->api->getBaseUrl()); - - $this->api->setBaseUrl(self::BASE_URL); - - $this->assertSame(self::BASE_URL, $this->api->getBaseUrl()); - } - - public function testQueryDefaults() - { - $this->api->addQueryDefault('test', true); - $this->assertTrue($this->api->getQueryDefault('test')); - - $this->api->removeQueryDefault('test'); - $this->assertNull($this->api->getQueryDefault('test')); - } - - public function testHeaderDefaults() - { - $this->api->addHeaderDefault('X-Test', true); - $this->assertTrue($this->api->getHeaderDefault('X-Test')); - - $this->api->removeHeaderDefault('X-Test'); - $this->assertNull($this->api->getHeaderDefault('X-Test')); - } - public function testConfigCanBeSetAndReadBySdkApi(): void { $api = new class extends Api { @@ -121,155 +40,4 @@ public function options(): array 'units' => 'metric', ], $api->options()); } - - public function testCache() - { - $this->assertNull($this->api->getCacheBuilder()); - - $cachePool = $this->createMock(CacheItemPoolInterface::class); - - $this->api->setCacheBuilder(new CacheBuilder($cachePool)); - - $cachePool->expects($this->once())->method('save'); - - $this->api->request( - method: 'GET', - path: '/path' - ); - } - - public function testLogger() - { - $this->assertNull($this->api->getLoggerBuilder()); - - $logger = $this->createMock(LoggerInterface::class); - - $this->api->setLoggerBuilder(new LoggerBuilder($logger)); - - // count equals 2 because of the request and response log - $logger->expects($this->exactly(2))->method('info'); - - $this->api->request( - method: 'GET', - path: '/path' - ); - } - - public function testCacheWithLogger() - { - $this->assertNull($this->api->getCacheBuilder()); - $this->assertNull($this->api->getLoggerBuilder()); - - $cachePool = $this->createMock(CacheItemPoolInterface::class); - $logger = $this->createMock(LoggerInterface::class); - - $this->api->setCacheBuilder(new CacheBuilder($cachePool)); - $this->api->setLoggerBuilder(new LoggerBuilder($logger)); - - // count equals 3 because of the request, response and cache log - $logger->expects($this->exactly(3))->method('info'); - - // error suppression to hide expected warning of null cache item in CacheLoggerListener - // https://docs.phpunit.de/en/10.5/error-handling.html#ignoring-issue-suppression - // TODO maybe allow user to add cache listeners to CacheBuilder and create a mock? - @$this->api->request( - method: 'GET', - path: '/path' - ); - } - - public function testAuthentication() - { - $this->assertNull($this->api->getAuthentication()); - - $authentication = $this->createConfiguredMock(Authentication::class, [ - 'authenticate' => $this->createMock(RequestInterface::class) - ]); - - $this->api->setAuthentication($authentication); - - $authentication->expects($this->once())->method('authenticate'); - - $this->api->request( - method: 'GET', - path: '/path' - ); - } - - public function testPreRequestListener() - { - $this->api->addPreRequestListener(fn() => throw new \Exception('TestMessage')); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('TestMessage'); - - $this->api->request( - method: 'GET', - path: '/path' - ); - } - - public function testPostRequestListener() - { - $this->api->addPostRequestListener(fn() => throw new \Exception('TestMessage')); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('TestMessage'); - - $this->api->request( - method: 'GET', - path: '/path' - ); - } - - public function testResponseContentsListener() - { - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - - $this->api->addResponseContentsListener(function(ResponseContentsEvent $event) { - $contents = json_decode($event->getContents(), true); - $event->setContents($contents); - }); - - $response = $this->api->request( - method: 'GET', - path: '/path' - ); - - $this->assertIsArray($response); - } - - #[DataProvider('provideBuildUrlData')] - public function testBuildUrl(?string $baseUrl, string $path, array $query, string $expectedUrl) - { - $this->api->addPreRequestListener(function(PreRequestEvent $event) use ($expectedUrl) { - $url = (string) $event->getRequest()->getUri(); - - $this->assertSame($expectedUrl, $url); - }); - - $this->api->setBaseUrl($baseUrl); - $this->api->request(method: 'GET', path: $path, query: $query); - } - - public static function provideBuildUrlData(): \Generator - { - yield 'no base url' => [null, '/path', [], '/path']; - yield 'base url' => [self::BASE_URL, '/path', [], 'https://base.com/url/path']; - yield 'path full url' => [self::BASE_URL, 'https://fullurl.com/path', [], 'https://fullurl.com/path']; - yield 'duplicated slashes' => [self::BASE_URL, '////path', [], 'https://base.com/url/path']; - yield 'query' => [self::BASE_URL, '/path', ['foo' => 'bar'], 'https://base.com/url/path?foo=bar']; - yield 'path query' => [self::BASE_URL, '/path?test=true', ['foo' => 'bar'], 'https://base.com/url/path?test=true&foo=bar']; - yield 'query replace' => [self::BASE_URL, '/path?test=true', ['test' => 'false'], 'https://base.com/url/path?test=false']; - } - - public function testBuildPath() - { - $path = $this->api->buildPath('/path/{parameter1}/multiple/{parameter2}', [ - 'parameter1' => 'with', - 'parameter2' => 'parameters' - ]); - - $this->assertSame('/path/with/multiple/parameters', $path); - } } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 233b4ca..7742efd 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -5,7 +5,7 @@ use Http\Mock\Client; use Nyholm\Psr7\Response; use Nyholm\Psr7\Stream; -use ProgrammatorDev\Api\Test\AbstractTestCase; +use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use ProgrammatorDev\Api\Test\Fixture\FakeApi; use ProgrammatorDev\Api\Test\Fixture\User; use ProgrammatorDev\Api\Test\Fixture\UserEnvelope; diff --git a/tests/Support/AbstractTestCase.php b/tests/Support/AbstractTestCase.php new file mode 100644 index 0000000..1a7975f --- /dev/null +++ b/tests/Support/AbstractTestCase.php @@ -0,0 +1,9 @@ + Date: Fri, 5 Jun 2026 12:01:00 +0100 Subject: [PATCH 09/88] feat(v3): expose SDK config to resources --- src/Api.php | 2 +- src/Resource.php | 5 +++++ tests/Fixture/FakeApi.php | 1 + tests/Fixture/UserResource.php | 8 ++++++++ tests/Integration/ApiTest.php | 31 +++++++----------------------- tests/Integration/ResourceTest.php | 11 +++++++++++ 6 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/Api.php b/src/Api.php index a2e06b6..24b9962 100644 --- a/src/Api.php +++ b/src/Api.php @@ -144,7 +144,7 @@ protected function responses(): ResponseBuilder return $this->responseBuilder; } - protected function config(?array $values = null): Config + public function config(?array $values = null): Config { if ($values !== null) { $this->config->merge($values); diff --git a/src/Resource.php b/src/Resource.php index 37fe417..4fef454 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -98,6 +98,11 @@ protected function options(string $path, array $pathParams = [], array $query = return $this->send(Method::OPTIONS, $path, $pathParams, $query); } + protected function config(): Config + { + return $this->api->config(); + } + protected function send(string $method, string $path, array $pathParams = [], array $query = []): Response { return $this->api->send( diff --git a/tests/Fixture/FakeApi.php b/tests/Fixture/FakeApi.php index 16fdd79..5a43baa 100644 --- a/tests/Fixture/FakeApi.php +++ b/tests/Fixture/FakeApi.php @@ -13,6 +13,7 @@ public function __construct(Client $client) parent::__construct(); $this->setClientBuilder(new ClientBuilder($client)); + $this->config(['timezone' => 'UTC']); $this ->baseUrl('https://api.example.com') diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 5079a34..58b70d3 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -74,4 +74,12 @@ public function findWithEndpointLocale(int|string $id, string $locale): User ->get('/users/{id}', ['id' => $id], ['locale' => $locale]) ->entity(User::class); } + + public function findWithConfiguredTimezone(int|string $id): User + { + return $this + ->query('timezone', $this->config()->get('timezone')) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } } diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index d1caddf..5f2c2c0 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -9,35 +9,18 @@ class ApiTest extends AbstractTestCase { public function testConfigCanBeSetAndReadBySdkApi(): void { - $api = new class extends Api { - public function setOptions(array $options): self - { - $this->config($options); - - return $this; - } - - public function option(string $key, mixed $default = null): mixed - { - return $this->config()->get($key, $default); - } - - public function options(): array - { - return $this->config()->all(); - } - }; + $api = new class extends Api {}; $api - ->setOptions(['timezone' => 'UTC']) - ->setOptions(['units' => 'metric']); + ->config(['timezone' => 'UTC']) + ->merge(['units' => 'metric']); - $this->assertSame('UTC', $api->option('timezone')); - $this->assertSame('metric', $api->option('units')); - $this->assertSame('en', $api->option('locale', 'en')); + $this->assertSame('UTC', $api->config()->get('timezone')); + $this->assertSame('metric', $api->config()->get('units')); + $this->assertSame('en', $api->config()->get('locale', 'en')); $this->assertSame([ 'timezone' => 'UTC', 'units' => 'metric', - ], $api->options()); + ], $api->config()->all()); } } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 7742efd..63140ea 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -145,6 +145,17 @@ public function testEndpointQueryOverridesResourceAndGlobalDefaults(): void $this->assertSame('https://api.example.com/users/1?locale=pt', (string) $this->client->getLastRequest()->getUri()); } + public function testResourceCanReadSdkConfig(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->users() + ->findWithConfiguredTimezone(1); + + $this->assertSame('https://api.example.com/users/1?locale=en&timezone=UTC', (string) $this->client->getLastRequest()->getUri()); + } + public function testNullQueryValuesAreOmitted(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); From 064e30412a3d0945037fee71349bd07b5bead8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 12:20:06 +0100 Subject: [PATCH 10/88] feat(v3): add shared hydration context --- docs/getting-started.md | 6 ++-- docs/resource-authoring.md | 8 ++++-- docs/v3-architecture-plan.md | 8 +++--- src/Api.php | 3 +- src/Context.php | 15 ++++++++++ src/Entity.php | 2 +- src/Response.php | 19 +++++++++---- src/ResponseEnvelope.php | 2 +- tests/Fixture/User.php | 14 ++++++++-- tests/Fixture/UserEnvelope.php | 14 ++++++++-- tests/Integration/ResourceTest.php | 3 ++ tests/Unit/ContextTest.php | 26 +++++++++++++++++ tests/Unit/ResponseTest.php | 45 ++++++++++++++++++++++++++++++ 13 files changed, 142 insertions(+), 23 deletions(-) create mode 100644 src/Context.php create mode 100644 tests/Unit/ContextTest.php diff --git a/docs/getting-started.md b/docs/getting-started.md index d67e605..fa7efc3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -47,6 +47,7 @@ $user = $api->users()->find(1); Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `Entity`. ```php +use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Entity; final class User implements Entity @@ -56,7 +57,7 @@ final class User implements Entity public readonly string $name, ) {} - public static function fromArray(array $data): static + public static function fromArray(array $data, ?Context $context = null): static { return new self( id: $data['id'], @@ -128,6 +129,7 @@ $activeUsers = $api If an API returns metadata, pagination, or any custom envelope, create a response envelope class. ```php +use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Response; use ProgrammatorDev\Api\ResponseEnvelope; @@ -138,7 +140,7 @@ final class UserResponse implements ResponseEnvelope public readonly int $statusCode, ) {} - public static function fromResponse(Response $response): static + public static function fromResponse(Response $response, ?Context $context = null): static { return new self( user: $response->entity(User::class, key: 'data'), diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index 47487c4..951da7c 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -140,9 +140,11 @@ return $this Entities must implement `Entity`: ```php +use ProgrammatorDev\Api\Context; + final class User implements Entity { - public static function fromArray(array $data): static + public static function fromArray(array $data, ?Context $context = null): static { return new self( id: $data['id'], @@ -181,6 +183,8 @@ return $this Envelope classes must implement `ResponseEnvelope`: ```php +use ProgrammatorDev\Api\Context; + final class UserResponse implements ResponseEnvelope { public function __construct( @@ -188,7 +192,7 @@ final class UserResponse implements ResponseEnvelope private readonly int $statusCode, ) {} - public static function fromResponse(Response $response): static + public static function fromResponse(Response $response, ?Context $context = null): static { return new self( user: $response->entity(User::class, key: 'data'), diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index e2e1183..f17ebcc 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -122,7 +122,7 @@ Custom envelopes should implement: ```php interface ResponseEnvelope { - public static function fromResponse(Response $response): static; + public static function fromResponse(Response $response, ?Context $context = null): static; } ``` @@ -164,11 +164,11 @@ Proposed contract: ```php interface Entity { - public static function fromArray(array $data, ?EntityContext $context = null): static; + public static function fromArray(array $data, ?Context $context = null): static; } ``` -`EntityContext` should provide access to SDK config and response context without injecting the full `Api` into entities. +`Context` should provide access to SDK config without injecting the full `Api` into entities or response envelopes. Start with a minimal context API. Add richer access only when implementation needs it. @@ -340,7 +340,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - SDK config should be available through context objects, not by injecting `Api` into entities. - `Response::entity()` and `Response::collection()` should require classes that implement `Entity`. - `Response::as()` should support API-specific response envelope classes such as item, collection, metadata, and pagination responses. -- `Response::as()` should require a `ResponseEnvelope` contract with `fromResponse(Response $response)`. +- `Response::as()` should require a `ResponseEnvelope` contract with `fromResponse(Response $response, ?Context $context = null)`. - `Response::collection()` should return a plain array by default. - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. - Symfony EventDispatcher should be replaced with a smaller request/response pipeline. diff --git a/src/Api.php b/src/Api.php index 24b9962..8c1ca0c 100644 --- a/src/Api.php +++ b/src/Api.php @@ -100,7 +100,8 @@ public function send( return new Response( data: $this->getResponseData($response), - rawResponse: $response + rawResponse: $response, + context: new Context($this->config) ); } diff --git a/src/Context.php b/src/Context.php new file mode 100644 index 0000000..963bb82 --- /dev/null +++ b/src/Context.php @@ -0,0 +1,15 @@ +config; + } +} diff --git a/src/Entity.php b/src/Entity.php index 736aef6..4de4661 100644 --- a/src/Entity.php +++ b/src/Entity.php @@ -4,5 +4,5 @@ interface Entity { - public static function fromArray(array $data): static; + public static function fromArray(array $data, ?Context $context = null): static; } diff --git a/src/Response.php b/src/Response.php index cf6d1de..9850c85 100644 --- a/src/Response.php +++ b/src/Response.php @@ -8,8 +8,13 @@ class Response { public function __construct( private readonly mixed $data, - private readonly ResponseInterface $rawResponse - ) {} + private readonly ResponseInterface $rawResponse, + ?Context $context = null + ) { + $this->context = $context ?? new Context(); + } + + private readonly Context $context; public function data(): mixed { @@ -36,7 +41,7 @@ public function entity(string $class, ?string $key = null): Entity throw new \UnexpectedValueException('Entity data must be an array.'); } - return $class::fromArray($data); + return $class::fromArray($data, $this->context); } /** @@ -54,12 +59,14 @@ public function collection(string $class, ?string $key = null): array throw new \UnexpectedValueException('Collection data must be an array.'); } - return array_map(static function (mixed $item) use ($class): Entity { + $context = $this->context; + + return array_map(static function (mixed $item) use ($class, $context): Entity { if (!is_array($item)) { throw new \UnexpectedValueException('Collection item data must be an array.'); } - return $class::fromArray($item); + return $class::fromArray($item, $context); }, $items); } @@ -72,7 +79,7 @@ public function as(string $class): ResponseEnvelope { $this->assertResponseEnvelopeClass($class); - return $class::fromResponse($this); + return $class::fromResponse($this, $this->context); } private function getData(?string $key): mixed diff --git a/src/ResponseEnvelope.php b/src/ResponseEnvelope.php index 88fd078..3619ca9 100644 --- a/src/ResponseEnvelope.php +++ b/src/ResponseEnvelope.php @@ -4,5 +4,5 @@ interface ResponseEnvelope { - public static function fromResponse(Response $response): static; + public static function fromResponse(Response $response, ?Context $context = null): static; } diff --git a/tests/Fixture/User.php b/tests/Fixture/User.php index 513551c..1e427b6 100644 --- a/tests/Fixture/User.php +++ b/tests/Fixture/User.php @@ -2,20 +2,23 @@ namespace ProgrammatorDev\Api\Test\Fixture; +use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Entity; class User implements Entity { public function __construct( private readonly int $id, - private readonly string $name + private readonly string $name, + private readonly ?string $timezone = null ) {} - public static function fromArray(array $data): static + public static function fromArray(array $data, ?Context $context = null): static { return new static( id: $data['id'], - name: $data['name'] + name: $data['name'], + timezone: $context?->config()->get('timezone') ); } @@ -28,4 +31,9 @@ public function getName(): string { return $this->name; } + + public function getTimezone(): ?string + { + return $this->timezone; + } } diff --git a/tests/Fixture/UserEnvelope.php b/tests/Fixture/UserEnvelope.php index 9f72ff4..9448b9f 100644 --- a/tests/Fixture/UserEnvelope.php +++ b/tests/Fixture/UserEnvelope.php @@ -2,6 +2,7 @@ namespace ProgrammatorDev\Api\Test\Fixture; +use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Response; use ProgrammatorDev\Api\ResponseEnvelope; @@ -9,14 +10,16 @@ class UserEnvelope implements ResponseEnvelope { public function __construct( private readonly User $user, - private readonly int $statusCode + private readonly int $statusCode, + private readonly ?string $timezone = null ) {} - public static function fromResponse(Response $response): static + public static function fromResponse(Response $response, ?Context $context = null): static { return new static( user: $response->entity(User::class, key: 'data'), - statusCode: $response->raw()->getStatusCode() + statusCode: $response->raw()->getStatusCode(), + timezone: $context?->config()->get('timezone') ); } @@ -29,4 +32,9 @@ public function getStatusCode(): int { return $this->statusCode; } + + public function getTimezone(): ?string + { + return $this->timezone; + } } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 63140ea..6b3e4d8 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -33,6 +33,7 @@ public function testResourceGetMapsEntity(): void $this->assertInstanceOf(User::class, $user); $this->assertSame(1, $user->getId()); $this->assertSame('John', $user->getName()); + $this->assertSame('UTC', $user->getTimezone()); $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); } @@ -199,8 +200,10 @@ public function testResponseCanBeMappedToEnvelope(): void $this->assertInstanceOf(UserEnvelope::class, $envelope); $this->assertSame(202, $envelope->getStatusCode()); + $this->assertSame('UTC', $envelope->getTimezone()); $this->assertSame(1, $envelope->getUser()->getId()); $this->assertSame('John', $envelope->getUser()->getName()); + $this->assertSame('UTC', $envelope->getUser()->getTimezone()); } public static function resourceVerbProvider(): array diff --git a/tests/Unit/ContextTest.php b/tests/Unit/ContextTest.php new file mode 100644 index 0000000..e3ba4d9 --- /dev/null +++ b/tests/Unit/ContextTest.php @@ -0,0 +1,26 @@ +assertFalse($context->config()->has('timezone')); + } + + public function testContextReturnsProvidedConfig(): void + { + $config = new Config(['timezone' => 'UTC']); + $context = new Context($config); + + $this->assertSame($config, $context->config()); + $this->assertSame('UTC', $context->config()->get('timezone')); + } +} diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 9881cdb..37cc043 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -3,6 +3,8 @@ namespace ProgrammatorDev\Api\Test\Unit; use Nyholm\Psr7\Response as PsrResponse; +use ProgrammatorDev\Api\Config; +use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Response; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use ProgrammatorDev\Api\Test\Fixture\User; @@ -26,6 +28,48 @@ public function testRawReturnsPsrResponse(): void $this->assertSame($rawResponse, $response->raw()); } + public function testEnvelopeReceivesEmptyContextByDefault(): void + { + $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse()); + + $envelope = $response->as(UserEnvelope::class); + + $this->assertNull($envelope->getTimezone()); + } + + public function testEnvelopeReceivesContext(): void + { + $config = new Config(['timezone' => 'UTC']); + $context = new Context($config); + $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse(), $context); + + $envelope = $response->as(UserEnvelope::class); + + $this->assertSame('UTC', $envelope->getTimezone()); + } + + public function testEntityReceivesContext(): void + { + $context = new Context(new Config(['timezone' => 'UTC'])); + $response = new Response(['id' => 1, 'name' => 'John'], new PsrResponse(), $context); + + $user = $response->entity(User::class); + + $this->assertSame('UTC', $user->getTimezone()); + } + + public function testCollectionReceivesContext(): void + { + $context = new Context(new Config(['timezone' => 'UTC'])); + $response = new Response([ + ['id' => 1, 'name' => 'John'], + ], new PsrResponse(), $context); + + $users = $response->collection(User::class); + + $this->assertSame('UTC', $users[0]->getTimezone()); + } + public function testEntityCanBeMappedFromRootData(): void { $response = new Response(['id' => 1, 'name' => 'John'], new PsrResponse()); @@ -182,5 +226,6 @@ public function testEnvelopeCanBeMapped(): void $this->assertSame(202, $envelope->getStatusCode()); $this->assertSame(1, $envelope->getUser()->getId()); $this->assertSame('John', $envelope->getUser()->getName()); + $this->assertNull($envelope->getTimezone()); } } From fdb4e471bd792331431bf80f9ff82fdd5fd2b072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 12:36:46 +0100 Subject: [PATCH 11/88] docs(v3): split API reference --- docs/api-reference.md | 7 +++ docs/api.md | 126 +++++++++++++++++++++++++++++++++++++ docs/getting-started.md | 1 - docs/index.md | 1 + docs/resource-authoring.md | 77 +++++++++++++++++++++++ docs/resources.md | 117 ++++++++++++++++++++++++++++++++++ docs/responses.md | 92 +++++++++++++++++++++++++++ 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 docs/api-reference.md create mode 100644 docs/api.md create mode 100644 docs/resources.md create mode 100644 docs/responses.md diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..2bd68f5 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,7 @@ +# API Reference + +This reference is split by where methods are available. + +- [API](api.md): `Api` setup methods and `Config`. +- [Resources](resources.md): resource modifiers and protected request helpers. +- [Responses](responses.md): `Response`, `Entity`, `ResponseEnvelope`, and `Context`. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..b5b8bc0 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,126 @@ +# API + +`Api` is the SDK facade. Concrete SDKs extend it and expose resources through purpose-built methods. + +Methods not listed here are legacy, internal, or still being reshaped for v3. + +## `config(?array $values = null): Config` + +Public. + +Sets SDK options when an array is provided and always returns the config bag. + +```php +$this->config(['timezone' => 'UTC']); + +$timezone = $this->config()->get('timezone'); +``` + +SDK users can also read or update options: + +```php +$api->config(['timezone' => 'UTC']); +$api->config()->get('timezone'); +``` + +## `resource(string $class): Resource` + +Protected helper for creating resource instances from an API class. + +```php +final class ExampleApi extends Api +{ + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +## `baseUrl(?string $baseUrl): static` + +Protected fluent helper for configuring the API base URL. + +```php +$this->baseUrl('https://api.example.com'); +``` + +Full request URLs passed to resources override the configured base URL. + +## `queryDefaults(array $query): static` + +Protected fluent helper for configuring query parameters applied to every request. + +```php +$this->queryDefaults(['api_key' => $apiKey, 'locale' => 'en']); +``` + +Query merge order is: + +```text +API defaults < resource options < endpoint-specific options +``` + +## `headerDefaults(array $headers): static` + +Protected fluent helper for configuring headers applied to every request. + +```php +$this->headerDefaults(['Accept' => 'application/json']); +``` + +Header names are not normalized by the package. + +## `responses(): ResponseBuilder` + +Protected access to response decoding configuration. + +```php +$this->responses()->json(); +``` + +This area is still small and will grow as response decoding, transforms, and errors are finalized. + +## `Config` + +`Config` stores SDK options. + +### `all(): array` + +Returns all option values. + +```php +$options = $api->config()->all(); +``` + +### `has(string $key): bool` + +Checks whether an option exists. A key with a `null` value still exists. + +```php +$api->config()->has('timezone'); +``` + +### `get(string $key, mixed $default = null): mixed` + +Returns an option value or the default when the key does not exist. + +```php +$timezone = $api->config()->get('timezone', 'UTC'); +``` + +### `set(string $key, mixed $value): self` + +Sets one option value. + +```php +$api->config()->set('timezone', 'UTC'); +``` + +### `merge(array $values): self` + +Sets multiple option values. + +```php +$api->config()->merge(['timezone' => 'UTC', 'units' => 'metric']); +``` diff --git a/docs/getting-started.md b/docs/getting-started.md index fa7efc3..f3e8293 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -164,4 +164,3 @@ public function findWithMeta(int $id): UserResponse ## Next Steps - Read [Resource Authoring](resource-authoring.md) for the full resource API. -- Read [Architecture Plan](v3-architecture-plan.md) for current decisions and remaining v3 work. diff --git a/docs/index.md b/docs/index.md index d16e5c4..767d7cd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,3 +24,4 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. +- [API Reference](api-reference.md): current v3 authoring methods and contracts. diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index 951da7c..14bd771 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -172,6 +172,83 @@ return $this `collection()` returns a plain array of entities. +## Context + +`Context` carries SDK options into response mapping without passing the full `Api` instance around. + +The flow is: + +```text +SDK constructor options -> Api config -> Context -> Entity or ResponseEnvelope +``` + +Start by accepting SDK options and storing them in config: + +```php +final class ExampleApi extends Api +{ + public function __construct(string $apiKey, array $options = []) + { + parent::__construct(); + + $this + ->baseUrl('https://api.example.com') + ->queryDefaults(['api_key' => $apiKey]); + + $this->config($options); + } +} +``` + +When a response is mapped, the API creates a context with that config. The same context is passed to: + +- `Entity::fromArray(array $data, ?Context $context = null)` +- `ResponseEnvelope::fromResponse(Response $response, ?Context $context = null)` + +Entities can use config values during hydration: + +```php +final class User implements Entity +{ + public function __construct( + private readonly int $id, + private readonly string $name, + private readonly ?string $timezone, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self( + id: $data['id'], + name: $data['name'], + timezone: $context?->config()->get('timezone'), + ); + } +} +``` + +Response envelopes receive the same context: + +```php +final class UserResponse implements ResponseEnvelope +{ + public function __construct( + private readonly User $user, + private readonly ?string $timezone, + ) {} + + public static function fromResponse(Response $response, ?Context $context = null): static + { + return new self( + user: $response->entity(User::class, key: 'data'), + timezone: $context?->config()->get('timezone'), + ); + } +} +``` + +Keep context usage focused on hydration decisions. Entities should still be data/value objects by default and should not perform hidden network calls. + Use `as()` when the response carries metadata, pagination, or any API-specific envelope: ```php diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 0000000..2927bf4 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,117 @@ +# Resources + +`Resource` is the base class for endpoint groups. + +Public resource modifiers are available on resource instances. Protected request helpers are for SDK resource classes. + +## `query(string $name, mixed $value): static` + +Public resource modifier. + +Returns a cloned resource with one query option. + +```php +return $this + ->query('active', true) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +Null query values are omitted. + +## `queries(array $query): static` + +Public resource modifier. + +Returns a cloned resource with multiple query options. + +```php +$this->queries(['active' => true, 'locale' => 'pt']); +``` + +## `header(string $name, mixed $value): static` + +Public resource modifier. + +Returns a cloned resource with one header option. + +```php +$this->header('X-Tenant', $tenant); +``` + +## `headers(array $headers): static` + +Public resource modifier. + +Returns a cloned resource with multiple header options. + +```php +$this->headers(['X-Tenant' => $tenant]); +``` + +## `json(array $data): static` + +Public resource modifier. + +Sets a JSON request body and `Content-Type: application/json`. + +```php +return $this + ->json(['name' => 'John']) + ->post('/users') + ->entity(User::class); +``` + +## `form(array $data): static` + +Public resource modifier. + +Sets a form-encoded request body and `Content-Type: application/x-www-form-urlencoded`. + +```php +$this->form(['name' => 'John Doe']); +``` + +## `body(mixed $body): static` + +Public resource modifier. + +Sets a raw string, stream, or null request body. + +```php +$this->body($stream); +``` + +Passing an array throws. Use `json()` or `form()` for array data. + +## HTTP Helpers + +Protected resource helpers execute the request immediately and return `Response`: + +```php +$this->get('/users'); +$this->post('/users'); +$this->put('/users/{id}', ['id' => $id]); +$this->patch('/users/{id}', ['id' => $id]); +$this->delete('/users/{id}', ['id' => $id]); +$this->head('/users'); +$this->options('/users'); +``` + +All helpers accept: + +```php +string $path +array $pathParams = [] +array $query = [] +``` + +## `send(string $method, string $path, array $pathParams = [], array $query = []): Response` + +Protected escape hatch for methods without a named helper. + +```php +return $this + ->send('TRACE', '/debug') + ->raw(); +``` diff --git a/docs/responses.md b/docs/responses.md new file mode 100644 index 0000000..b2c6a0f --- /dev/null +++ b/docs/responses.md @@ -0,0 +1,92 @@ +# Responses + +Response mapping covers decoded data, raw PSR responses, entities, collections, custom response envelopes, and hydration context. + +## `Response` + +`Response` wraps decoded response data and the raw PSR response. + +### `data(): mixed` + +Returns decoded response data. + +```php +$data = $response->data(); +``` + +### `raw(): ResponseInterface` + +Returns the raw PSR response. + +```php +$status = $response->raw()->getStatusCode(); +``` + +### `entity(string $class, ?string $key = null): Entity` + +Maps decoded response data to an entity class. + +```php +return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); +``` + +The class must implement `Entity`. + +### `collection(string $class, ?string $key = null): array` + +Maps list data to a plain array of entities. + +```php +return $this + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +### `as(string $class): ResponseEnvelope` + +Maps the response to a custom envelope. + +```php +return $this + ->get('/users/{id}', ['id' => $id]) + ->as(UserResponse::class); +``` + +The class must implement `ResponseEnvelope`. + +## `Entity` + +Entities used by response mapping must implement: + +```php +public static function fromArray(array $data, ?Context $context = null): static; +``` + +## `ResponseEnvelope` + +Response envelopes used by `Response::as()` must implement: + +```php +public static function fromResponse(Response $response, ?Context $context = null): static; +``` + +## `Context` + +`Context` carries SDK config into response mapping. + +SDK users do not fetch context from `Response`. The package passes context into entity and envelope hydration methods: + +```php +Entity::fromArray(array $data, ?Context $context = null) +ResponseEnvelope::fromResponse(Response $response, ?Context $context = null) +``` + +### `config(): Config` + +Returns the SDK config available while hydrating entities or response envelopes. + +```php +$timezone = $context?->config()->get('timezone'); +``` From f6402b76837dcd40765dc1cbad73beacbdd59cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 12:43:39 +0100 Subject: [PATCH 12/88] test(v3): cover response decoding --- docs/api.md | 10 ++++- docs/responses.md | 6 ++- tests/Fixture/JsonApi.php | 27 +++++++++++ tests/Fixture/PlainApi.php | 23 ++++++++++ tests/Fixture/RawResource.php | 14 ++++++ tests/Integration/ResponseDecodingTest.php | 52 ++++++++++++++++++++++ 6 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/Fixture/JsonApi.php create mode 100644 tests/Fixture/PlainApi.php create mode 100644 tests/Fixture/RawResource.php create mode 100644 tests/Integration/ResponseDecodingTest.php diff --git a/docs/api.md b/docs/api.md index b5b8bc0..c9e8e22 100644 --- a/docs/api.md +++ b/docs/api.md @@ -79,7 +79,15 @@ Protected access to response decoding configuration. $this->responses()->json(); ``` -This area is still small and will grow as response decoding, transforms, and errors are finalized. +When JSON decoding is enabled: + +- JSON response bodies are decoded into arrays. +- Empty response bodies become `null`. +- Invalid JSON throws `JsonException`. + +When JSON decoding is not enabled, `Response::data()` returns the raw response body string. + +This area will grow as response transforms and errors are finalized. ## `Config` diff --git a/docs/responses.md b/docs/responses.md index b2c6a0f..274b449 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -8,7 +8,11 @@ Response mapping covers decoded data, raw PSR responses, entities, collections, ### `data(): mixed` -Returns decoded response data. +Returns response data. + +When `responses()->json()` is enabled on the API, JSON bodies are decoded into arrays, empty bodies become `null`, and invalid JSON throws `JsonException`. + +When JSON decoding is not enabled, this returns the raw response body string. ```php $data = $response->data(); diff --git a/tests/Fixture/JsonApi.php b/tests/Fixture/JsonApi.php new file mode 100644 index 0000000..f38ff43 --- /dev/null +++ b/tests/Fixture/JsonApi.php @@ -0,0 +1,27 @@ +setClientBuilder(new ClientBuilder($client)); + + $this + ->baseUrl('https://api.example.com') + ->responses() + ->json(); + } + + public function raw(): RawResource + { + return $this->resource(RawResource::class); + } +} diff --git a/tests/Fixture/PlainApi.php b/tests/Fixture/PlainApi.php new file mode 100644 index 0000000..f8644c5 --- /dev/null +++ b/tests/Fixture/PlainApi.php @@ -0,0 +1,23 @@ +setClientBuilder(new ClientBuilder($client)); + $this->baseUrl('https://api.example.com'); + } + + public function raw(): RawResource + { + return $this->resource(RawResource::class); + } +} diff --git a/tests/Fixture/RawResource.php b/tests/Fixture/RawResource.php new file mode 100644 index 0000000..8ec3d32 --- /dev/null +++ b/tests/Fixture/RawResource.php @@ -0,0 +1,14 @@ +get('/raw'); + } +} diff --git a/tests/Integration/ResponseDecodingTest.php b/tests/Integration/ResponseDecodingTest.php new file mode 100644 index 0000000..8e41ca1 --- /dev/null +++ b/tests/Integration/ResponseDecodingTest.php @@ -0,0 +1,52 @@ +addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $response = (new PlainApi($client))->raw()->fetch(); + + $this->assertSame('{"id":1,"name":"John"}', $response->data()); + } + + public function testResponseDataIsDecodedWhenJsonDecodingIsEnabled(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $response = (new JsonApi($client))->raw()->fetch(); + + $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); + } + + public function testEmptyJsonResponseBodyDecodesToNull(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '')); + + $response = (new JsonApi($client))->raw()->fetch(); + + $this->assertNull($response->data()); + } + + public function testInvalidJsonThrowsWhenJsonDecodingIsEnabled(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{invalid-json')); + + $this->expectException(\JsonException::class); + + (new JsonApi($client))->raw()->fetch(); + } +} From 4db668bf927dc9b630689d7c09115a26814114a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 16:18:06 +0100 Subject: [PATCH 13/88] feat(v3): add fluent error mapping --- docs/api.md | 44 ++++++++ docs/responses.md | 37 +++++++ src/Api.php | 19 +++- src/Builder/ErrorBuilder.php | 78 +++++++++++++++ src/Context/ErrorContext.php | 29 ++++++ tests/Fixture/InvalidApiKeyException.php | 7 ++ tests/Fixture/JsonApi.php | 40 ++++++++ tests/Fixture/NotFoundException.php | 7 ++ tests/Integration/ErrorHandlingTest.php | 92 +++++++++++++++++ tests/Unit/Builder/ErrorBuilderTest.php | 122 +++++++++++++++++++++++ tests/Unit/Context/ErrorContextTest.php | 24 +++++ 11 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 src/Builder/ErrorBuilder.php create mode 100644 src/Context/ErrorContext.php create mode 100644 tests/Fixture/InvalidApiKeyException.php create mode 100644 tests/Fixture/NotFoundException.php create mode 100644 tests/Integration/ErrorHandlingTest.php create mode 100644 tests/Unit/Builder/ErrorBuilderTest.php create mode 100644 tests/Unit/Context/ErrorContextTest.php diff --git a/docs/api.md b/docs/api.md index c9e8e22..d3346ae 100644 --- a/docs/api.md +++ b/docs/api.md @@ -89,6 +89,50 @@ When JSON decoding is not enabled, `Response::data()` returns the raw response b This area will grow as response transforms and errors are finalized. +## `errors(): ErrorBuilder` + +Protected access to error handling configuration. + +By default, HTTP error status codes do not throw. SDK authors opt in to error behavior: + +```php +$this->errors()->status(404, NotFoundException::class); +``` + +Use `statuses()` when an SDK has a common status-to-exception map: + +```php +$this->errors()->statuses([ + 400 => BadRequestException::class, + 401 => UnauthorizedException::class, + 403 => ForbiddenException::class, + 404 => NotFoundException::class, + 429 => TooManyRequestsException::class, +]); +``` + +Use a callback when the exception needs response data: + +```php +$this->errors()->status(404, function (ErrorContext $context): Throwable { + return new NotFoundException($context->response()->data()['message']); +}); +``` + +Use `when()` for API-specific error payloads that are not represented by status alone: + +```php +$this->errors()->when(function (ErrorContext $context): ?Throwable { + if (($context->response()->data()['code'] ?? null) !== 'invalid_api_key') { + return null; + } + + return new InvalidApiKeyException($context->response()->data()['message']); +}); +``` + +Status callbacks receive `ErrorContext` and must return a `Throwable`. Custom `when()` handlers receive `ErrorContext` and must return a `Throwable` when matched or `null` when not matched. + ## `Config` `Config` stores SDK options. diff --git a/docs/responses.md b/docs/responses.md index 274b449..a56d838 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -94,3 +94,40 @@ Returns the SDK config available while hydrating entities or response envelopes. ```php $timezone = $context?->config()->get('timezone'); ``` + +## `ErrorContext` + +`ErrorContext` is passed to configured error handlers. + +```php +$this->errors()->status(404, NotFoundException::class); +``` + +```php +$this->errors()->statuses([ + 401 => UnauthorizedException::class, + 404 => NotFoundException::class, +]); +``` + +```php +$this->errors()->status(404, function (ErrorContext $context): Throwable { + return new NotFoundException($context->response()->data()['message']); +}); +``` + +```php +$this->errors()->when(function (ErrorContext $context): ?Throwable { + if (($context->response()->data()['code'] ?? null) !== 'invalid_api_key') { + return null; + } + + return new InvalidApiKeyException($context->response()->data()['message']); +}); +``` + +It exposes: + +- `response(): Response` +- `context(): Context` +- `statusCode(): int` diff --git a/src/Api.php b/src/Api.php index 8c1ca0c..8c821f0 100644 --- a/src/Api.php +++ b/src/Api.php @@ -10,9 +10,11 @@ use Http\Message\Authentication; use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; +use ProgrammatorDev\Api\Builder\ErrorBuilder; use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener; use ProgrammatorDev\Api\Builder\LoggerBuilder; use ProgrammatorDev\Api\Builder\ResponseBuilder; +use ProgrammatorDev\Api\Context\ErrorContext; use ProgrammatorDev\Api\Event\PostRequestEvent; use ProgrammatorDev\Api\Event\PreRequestEvent; use ProgrammatorDev\Api\Event\ResponseContentsEvent; @@ -44,6 +46,8 @@ class Api private ResponseBuilder $responseBuilder; + private ErrorBuilder $errorBuilder; + private EventDispatcher $eventDispatcher; public function __construct() @@ -51,6 +55,7 @@ public function __construct() $this->config = new Config(); $this->clientBuilder ??= new ClientBuilder(); $this->responseBuilder = new ResponseBuilder(); + $this->errorBuilder = new ErrorBuilder(); $this->eventDispatcher = new EventDispatcher(); } @@ -98,11 +103,16 @@ public function send( body: $options->getBody() ); - return new Response( + $context = new Context($this->config); + $apiResponse = new Response( data: $this->getResponseData($response), rawResponse: $response, - context: new Context($this->config) + context: $context ); + + $this->errorBuilder->throwIfMatched(new ErrorContext($apiResponse, $context)); + + return $apiResponse; } /** @@ -145,6 +155,11 @@ protected function responses(): ResponseBuilder return $this->responseBuilder; } + protected function errors(): ErrorBuilder + { + return $this->errorBuilder; + } + public function config(?array $values = null): Config { if ($values !== null) { diff --git a/src/Builder/ErrorBuilder.php b/src/Builder/ErrorBuilder.php new file mode 100644 index 0000000..a293f08 --- /dev/null +++ b/src/Builder/ErrorBuilder.php @@ -0,0 +1,78 @@ +|callable(ErrorContext): \Throwable> */ + private array $statusHandlers = []; + + /** @var array */ + private array $handlers = []; + + /** + * @param class-string<\Throwable>|callable(ErrorContext): \Throwable $handler + */ + public function status(int $statusCode, string|callable $handler): self + { + if (is_string($handler) && ! is_a($handler, \Throwable::class, true)) { + throw new \InvalidArgumentException(sprintf( + 'Error handler for status %d must be a Throwable class or callable.', + $statusCode + )); + } + + $this->statusHandlers[$statusCode] = $handler; + + return $this; + } + + /** + * @param array|callable(ErrorContext): \Throwable> $handlers + */ + public function statuses(array $handlers): self + { + foreach ($handlers as $statusCode => $handler) { + $this->status($statusCode, $handler); + } + + return $this; + } + + /** + * @param callable(ErrorContext): ?\Throwable $handler + */ + public function when(callable $handler): self + { + $this->handlers[] = $handler; + + return $this; + } + + public function throwIfMatched(ErrorContext $context): void + { + $handler = $this->statusHandlers[$context->statusCode()] ?? null; + + if (is_string($handler)) { + throw new $handler(); + } + + if ($handler !== null) { + throw $handler($context); + } + + foreach ($this->handlers as $handler) { + $throwable = $handler($context); + + if ($throwable instanceof \Throwable) { + throw $throwable; + } + + if ($throwable !== null) { + throw new \UnexpectedValueException('Error handler must return a Throwable or null.'); + } + } + } +} diff --git a/src/Context/ErrorContext.php b/src/Context/ErrorContext.php new file mode 100644 index 0000000..acc4ee3 --- /dev/null +++ b/src/Context/ErrorContext.php @@ -0,0 +1,29 @@ +response; + } + + public function context(): Context + { + return $this->context; + } + + public function statusCode(): int + { + return $this->response->raw()->getStatusCode(); + } +} diff --git a/tests/Fixture/InvalidApiKeyException.php b/tests/Fixture/InvalidApiKeyException.php new file mode 100644 index 0000000..08b5da6 --- /dev/null +++ b/tests/Fixture/InvalidApiKeyException.php @@ -0,0 +1,7 @@ +json(); } + public function throwNotFoundErrors(): self + { + $this->errors()->status(404, function (ErrorContext $context): \Throwable { + return new NotFoundException($context->response()->data()['message']); + }); + + return $this; + } + + public function throwSimpleNotFoundErrors(): self + { + $this->errors()->status(404, NotFoundException::class); + + return $this; + } + + public function throwStatusErrors(): self + { + $this->errors()->statuses([ + 401 => InvalidApiKeyException::class, + 404 => NotFoundException::class, + ]); + + return $this; + } + + public function throwInvalidApiKeyErrors(): self + { + $this->errors()->when(function (ErrorContext $context): ?\Throwable { + if (($context->response()->data()['code'] ?? null) !== 'invalid_api_key') { + return null; + } + + return new InvalidApiKeyException($context->response()->data()['message']); + }); + + return $this; + } + public function raw(): RawResource { return $this->resource(RawResource::class); diff --git a/tests/Fixture/NotFoundException.php b/tests/Fixture/NotFoundException.php new file mode 100644 index 0000000..7747bc7 --- /dev/null +++ b/tests/Fixture/NotFoundException.php @@ -0,0 +1,7 @@ +addResponse(new Response(status: 404, body: '{"message":"Missing user"}')); + + $response = (new JsonApi($client))->raw()->fetch(); + + $this->assertSame(404, $response->raw()->getStatusCode()); + $this->assertSame(['message' => 'Missing user'], $response->data()); + } + + public function testConfiguredStatusErrorThrows(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 404, body: '{"message":"Missing user"}')); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Missing user'); + + (new JsonApi($client)) + ->throwNotFoundErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredStatusErrorCanMapDirectlyToThrowableClass(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 404, body: '{"message":"Missing user"}')); + + $this->expectException(NotFoundException::class); + + (new JsonApi($client)) + ->throwSimpleNotFoundErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredStatusErrorCanMapMultipleStatuses(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 401, body: '{"message":"Invalid API key"}')); + + $this->expectException(InvalidApiKeyException::class); + + (new JsonApi($client)) + ->throwStatusErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredCustomErrorHandlerThrowsWhenMatched(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 401, body: '{"code":"invalid_api_key","message":"Invalid API key"}')); + + $this->expectException(InvalidApiKeyException::class); + $this->expectExceptionMessage('Invalid API key'); + + (new JsonApi($client)) + ->throwInvalidApiKeyErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredCustomErrorHandlerDoesNotThrowWhenUnmatched(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 401, body: '{"code":"rate_limited","message":"Too many requests"}')); + + $response = (new JsonApi($client)) + ->throwInvalidApiKeyErrors() + ->raw() + ->fetch(); + + $this->assertSame(401, $response->raw()->getStatusCode()); + $this->assertSame(['code' => 'rate_limited', 'message' => 'Too many requests'], $response->data()); + } +} diff --git a/tests/Unit/Builder/ErrorBuilderTest.php b/tests/Unit/Builder/ErrorBuilderTest.php new file mode 100644 index 0000000..cce2e0e --- /dev/null +++ b/tests/Unit/Builder/ErrorBuilderTest.php @@ -0,0 +1,122 @@ +status(404, fn(): \Throwable => new \RuntimeException('Not found')); + $builder->throwIfMatched($this->context(statusCode: 200)); + + $this->assertTrue(true); + } + + public function testMatchedStatusThrowsConfiguredThrowable(): void + { + $builder = new ErrorBuilder(); + $builder->status(404, fn(ErrorContext $context): \Throwable => new \RuntimeException( + sprintf('Status %d in %s', $context->statusCode(), $context->context()->config()->get('timezone')) + )); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Status 404 in UTC'); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testMatchedStatusThrowsConfiguredThrowableClass(): void + { + $builder = new ErrorBuilder(); + $builder->status(404, \RuntimeException::class); + + $this->expectException(\RuntimeException::class); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testMatchedStatusThrowsConfiguredThrowableFromStatusMap(): void + { + $builder = new ErrorBuilder(); + $builder->statuses([ + 401 => \UnexpectedValueException::class, + 404 => fn(): \Throwable => new \RuntimeException('Missing resource'), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing resource'); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testStatusHandlerRequiresThrowableClass(): void + { + $builder = new ErrorBuilder(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Error handler for status 404 must be a Throwable class or callable.'); + + $builder->status(404, \stdClass::class); + } + + public function testCustomHandlerThrowsWhenMatched(): void + { + $builder = new ErrorBuilder(); + $builder->when(function (ErrorContext $context): ?\Throwable { + if ($context->response()->data()['code'] !== 'invalid_api_key') { + return null; + } + + return new \RuntimeException('Invalid API key'); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid API key'); + + $builder->throwIfMatched($this->context(statusCode: 401, data: ['code' => 'invalid_api_key'])); + } + + public function testCustomHandlerDoesNotThrowWhenNotMatched(): void + { + $builder = new ErrorBuilder(); + $builder->when(fn(): ?\Throwable => null); + + $builder->throwIfMatched($this->context(statusCode: 200)); + + $this->assertTrue(true); + } + + public function testCustomHandlerMustReturnThrowableOrNull(): void + { + $builder = new ErrorBuilder(); + $builder->when(fn(): string => 'invalid'); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Error handler must return a Throwable or null.'); + + $builder->throwIfMatched($this->context(statusCode: 200)); + } + + /** + * @param array $data + */ + private function context(int $statusCode, array $data = []): ErrorContext + { + $context = new Context(new Config(['timezone' => 'UTC'])); + + return new ErrorContext( + response: new Response($data ?? [], new PsrResponse(status: $statusCode), $context), + context: $context + ); + } +} diff --git a/tests/Unit/Context/ErrorContextTest.php b/tests/Unit/Context/ErrorContextTest.php new file mode 100644 index 0000000..58d5286 --- /dev/null +++ b/tests/Unit/Context/ErrorContextTest.php @@ -0,0 +1,24 @@ + 'UTC'])); + $response = new Response([], new PsrResponse(status: 404), $context); + $errorContext = new ErrorContext($response, $context); + + $this->assertSame($response, $errorContext->response()); + $this->assertSame($context, $errorContext->context()); + $this->assertSame(404, $errorContext->statusCode()); + } +} From 02a2fb18dddb4bbef3c59ea57656a72ab9842a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 17:10:01 +0100 Subject: [PATCH 14/88] feat(v3): add fluent authentication builder --- docs/api-reference.md | 1 + docs/api.md | 14 +++ docs/authentication.md | 79 ++++++++++++++++ docs/getting-started.md | 4 +- docs/index.md | 1 + src/Api.php | 26 ++---- src/Authentication/CallbackAuthentication.php | 21 +++++ src/Builder/AuthBuilder.php | 70 ++++++++++++++ tests/Fixture/JsonApi.php | 46 +++++++++ tests/Integration/AuthenticationTest.php | 93 +++++++++++++++++++ tests/Unit/Builder/AuthBuilderTest.php | 65 +++++++++++++ 11 files changed, 403 insertions(+), 17 deletions(-) create mode 100644 docs/authentication.md create mode 100644 src/Authentication/CallbackAuthentication.php create mode 100644 src/Builder/AuthBuilder.php create mode 100644 tests/Integration/AuthenticationTest.php create mode 100644 tests/Unit/Builder/AuthBuilderTest.php diff --git a/docs/api-reference.md b/docs/api-reference.md index 2bd68f5..fee4d61 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -3,5 +3,6 @@ This reference is split by where methods are available. - [API](api.md): `Api` setup methods and `Config`. +- [Authentication](authentication.md): `AuthBuilder` helpers and custom authentication. - [Resources](resources.md): resource modifiers and protected request helpers. - [Responses](responses.md): `Response`, `Entity`, `ResponseEnvelope`, and `Context`. diff --git a/docs/api.md b/docs/api.md index d3346ae..c83912b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -71,6 +71,20 @@ $this->headerDefaults(['Accept' => 'application/json']); Header names are not normalized by the package. +## `auth(): AuthBuilder` + +Protected access to authentication configuration. + +```php +$this->auth() + ->bearer($token) + ->query('appid', $apiKey); +``` + +Authentication is applied automatically to outgoing requests. + +See [Authentication](authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. + ## `responses(): ResponseBuilder` Protected access to response decoding configuration. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..fae7f8c --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,79 @@ +# Authentication + +Authentication is configured by the SDK author from the `Api` class. + +`auth()` returns an `AuthBuilder`. Every configured authentication rule is applied automatically to outgoing requests. + +## Common Helpers + +Use the built-in helpers for common API authentication styles: + +```php +$this->auth()->bearer($token); +``` + +```php +$this->auth()->basic($username, $password); +``` + +```php +$this->auth()->header('X-Api-Key', $apiKey); +``` + +```php +$this->auth()->query('appid', $apiKey); +``` + +Multiple calls are chained in order: + +```php +$this->auth() + ->bearer($token) + ->query('appid', $apiKey); +``` + +Internally, multiple authentication rules become an HTTPlug authentication chain. + +## Query Authentication + +Use `query()` only when the API requires credentials in the URL. + +```php +$this->auth()->query('api_key', $apiKey); +``` + +For non-sensitive default query parameters such as `locale`, `units`, or `timezone`, use `queryDefaults()` instead: + +```php +$this->queryDefaults(['units' => 'metric']); +``` + +## HTTPlug Authentication Objects + +Use `chain()` when an SDK needs to reuse specific HTTPlug authentication implementations: + +```php +use Http\Message\Authentication\Bearer; +use Http\Message\Authentication\QueryParam; + +$this->auth()->chain( + new Bearer($token), + new QueryParam(['appid' => $apiKey]), +); +``` + +This is mostly useful when an SDK author already has an `Http\Message\Authentication` object or needs behavior provided by `php-http/message`. + +## Custom Authentication + +Use `custom()` for request-mutating authentication logic: + +```php +use Psr\Http\Message\RequestInterface; + +$this->auth()->custom(function (RequestInterface $request): RequestInterface { + return $request->withHeader('X-Custom-Auth', 'custom'); +}); +``` + +The callback receives the outgoing PSR request and must return a PSR request. diff --git a/docs/getting-started.md b/docs/getting-started.md index f3e8293..82f4043 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,8 +25,10 @@ final class ExampleApi extends Api $this ->baseUrl('https://api.example.com') - ->queryDefaults(['api_key' => $apiKey, 'locale' => 'en']) + ->queryDefaults(['locale' => 'en']) ->headerDefaults(['Accept' => 'application/json']); + + $this->auth()->query('api_key', $apiKey); } public function users(): UserResource diff --git a/docs/index.md b/docs/index.md index 767d7cd..780cac7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,5 +23,6 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement ## Guides - [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. +- [Authentication](authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. - [API Reference](api-reference.md): current v3 authoring methods and contracts. diff --git a/src/Api.php b/src/Api.php index 8c821f0..f91d409 100644 --- a/src/Api.php +++ b/src/Api.php @@ -7,7 +7,7 @@ use Http\Client\Common\Plugin\ContentLengthPlugin; use Http\Client\Common\Plugin\ContentTypePlugin; use Http\Client\Common\Plugin\LoggerPlugin; -use Http\Message\Authentication; +use ProgrammatorDev\Api\Builder\AuthBuilder; use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Builder\ErrorBuilder; @@ -42,7 +42,7 @@ class Api private ?LoggerBuilder $loggerBuilder = null; - private ?Authentication $authentication = null; + private AuthBuilder $authBuilder; private ResponseBuilder $responseBuilder; @@ -54,6 +54,7 @@ public function __construct() { $this->config = new Config(); $this->clientBuilder ??= new ClientBuilder(); + $this->authBuilder = new AuthBuilder(); $this->responseBuilder = new ResponseBuilder(); $this->errorBuilder = new ErrorBuilder(); $this->eventDispatcher = new EventDispatcher(); @@ -160,6 +161,11 @@ protected function errors(): ErrorBuilder return $this->errorBuilder; } + protected function auth(): AuthBuilder + { + return $this->authBuilder; + } + public function config(?array $values = null): Config { if ($values !== null) { @@ -184,9 +190,9 @@ private function configurePlugins(): void ); // https://docs.php-http.org/en/latest/message/authentication.html - if ($this->authentication) { + if ($authentication = $this->authBuilder->authentication()) { $this->clientBuilder->addPlugin( - plugin: new AuthenticationPlugin($this->authentication), + plugin: new AuthenticationPlugin($authentication), priority: 24 ); } @@ -312,18 +318,6 @@ public function setLoggerBuilder(?LoggerBuilder $loggerBuilder): self return $this; } - public function getAuthentication(): ?Authentication - { - return $this->authentication; - } - - public function setAuthentication(?Authentication $authentication): self - { - $this->authentication = $authentication; - - return $this; - } - public function addPreRequestListener(callable $listener, int $priority = 0): self { $this->eventDispatcher->addListener(PreRequestEvent::class, $listener, $priority); diff --git a/src/Authentication/CallbackAuthentication.php b/src/Authentication/CallbackAuthentication.php new file mode 100644 index 0000000..f826a7c --- /dev/null +++ b/src/Authentication/CallbackAuthentication.php @@ -0,0 +1,21 @@ +callback)($request); + } +} diff --git a/src/Builder/AuthBuilder.php b/src/Builder/AuthBuilder.php new file mode 100644 index 0000000..12e2801 --- /dev/null +++ b/src/Builder/AuthBuilder.php @@ -0,0 +1,70 @@ +chain(new Bearer($token)); + } + + public function basic(string $username, string $password): self + { + return $this->chain(new BasicAuth($username, $password)); + } + + public function header(string $name, string|array $value): self + { + return $this->chain(new Header($name, $value)); + } + + public function query(string $name, mixed $value): self + { + return $this->chain(new QueryParam([$name => $value])); + } + + public function chain(Authentication ...$authentications): self + { + foreach ($authentications as $authentication) { + $this->authentications[] = $authentication; + } + + return $this; + } + + /** + * @param callable(RequestInterface): RequestInterface $callback + */ + public function custom(callable $callback): self + { + $this->authentications[] = new CallbackAuthentication($callback); + + return $this; + } + + public function authentication(): ?Authentication + { + if ($this->authentications === []) { + return null; + } + + if (count($this->authentications) === 1) { + return $this->authentications[0]; + } + + return new Chain($this->authentications); + } +} diff --git a/tests/Fixture/JsonApi.php b/tests/Fixture/JsonApi.php index f8cbd92..aca9a05 100644 --- a/tests/Fixture/JsonApi.php +++ b/tests/Fixture/JsonApi.php @@ -3,9 +3,11 @@ namespace ProgrammatorDev\Api\Test\Fixture; use Http\Mock\Client; +use Http\Message\Authentication\Header as HeaderAuthentication; use ProgrammatorDev\Api\Api; use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Context\ErrorContext; +use Psr\Http\Message\RequestInterface; class JsonApi extends Api { @@ -60,6 +62,50 @@ public function throwInvalidApiKeyErrors(): self return $this; } + public function useBearerAuth(string $token): self + { + $this->auth()->bearer($token); + + return $this; + } + + public function useBasicAuth(string $username, string $password): self + { + $this->auth()->basic($username, $password); + + return $this; + } + + public function useHeaderAuth(string $name, string $value): self + { + $this->auth()->header($name, $value); + + return $this; + } + + public function useQueryAuth(string $name, string $value): self + { + $this->auth()->query($name, $value); + + return $this; + } + + public function useChainedAuth(string $headerName, string $headerValue): self + { + $this->auth()->chain(new HeaderAuthentication($headerName, $headerValue)); + + return $this; + } + + public function useCustomAuth(string $headerName, string $headerValue): self + { + $this->auth()->custom(function (RequestInterface $request) use ($headerName, $headerValue): RequestInterface { + return $request->withHeader($headerName, $headerValue); + }); + + return $this; + } + public function raw(): RawResource { return $this->resource(RawResource::class); diff --git a/tests/Integration/AuthenticationTest.php b/tests/Integration/AuthenticationTest.php new file mode 100644 index 0000000..eb648ae --- /dev/null +++ b/tests/Integration/AuthenticationTest.php @@ -0,0 +1,93 @@ +client(); + + (new JsonApi($client)) + ->useBearerAuth('secret') + ->raw() + ->fetch(); + + $this->assertSame('Bearer secret', $client->getLastRequest()->getHeaderLine('Authorization')); + } + + public function testBasicAuthenticationAddsAuthorizationHeader(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useBasicAuth('user', 'pass') + ->raw() + ->fetch(); + + $this->assertSame('Basic ' . base64_encode('user:pass'), $client->getLastRequest()->getHeaderLine('Authorization')); + } + + public function testHeaderAuthenticationAddsConfiguredHeader(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useHeaderAuth('X-Api-Key', 'secret') + ->raw() + ->fetch(); + + $this->assertSame('secret', $client->getLastRequest()->getHeaderLine('X-Api-Key')); + } + + public function testQueryAuthenticationAddsConfiguredQueryParameter(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useQueryAuth('appid', 'secret') + ->raw() + ->fetch(); + + parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + + $this->assertSame('secret', $query['appid']); + } + + public function testChainedAuthenticationCanBeUsed(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useChainedAuth('X-Chain-Auth', 'chain') + ->raw() + ->fetch(); + + $this->assertSame('chain', $client->getLastRequest()->getHeaderLine('X-Chain-Auth')); + } + + public function testCustomAuthenticationCallbackCanBeUsed(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useCustomAuth('X-Custom-Auth', 'custom') + ->raw() + ->fetch(); + + $this->assertSame('custom', $client->getLastRequest()->getHeaderLine('X-Custom-Auth')); + } + + private function client(): Client + { + $client = new Client(); + $client->addResponse(new Response(body: '{}')); + + return $client; + } +} diff --git a/tests/Unit/Builder/AuthBuilderTest.php b/tests/Unit/Builder/AuthBuilderTest.php new file mode 100644 index 0000000..8e751e6 --- /dev/null +++ b/tests/Unit/Builder/AuthBuilderTest.php @@ -0,0 +1,65 @@ +assertNull((new AuthBuilder())->authentication()); + } + + public function testSingleAuthenticationIsReturnedDirectly(): void + { + $authentication = (new AuthBuilder()) + ->bearer('token') + ->authentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com')); + + $this->assertSame('Bearer token', $request->getHeaderLine('Authorization')); + } + + public function testMultipleAuthenticationsAreReturnedAsChain(): void + { + $authentication = (new AuthBuilder()) + ->bearer('token') + ->query('appid', 'key') + ->authentication(); + + $this->assertInstanceOf(Chain::class, $authentication); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('Bearer token', $request->getHeaderLine('Authorization')); + $this->assertSame('appid=key', $request->getUri()->getQuery()); + } + + public function testExplicitChainAcceptsHttplugAuthenticationObjects(): void + { + $authentication = (new AuthBuilder()) + ->chain(new Header('X-Api-Key', 'secret')) + ->authentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('secret', $request->getHeaderLine('X-Api-Key')); + } + + public function testCustomAuthenticationUsesCallback(): void + { + $authentication = (new AuthBuilder()) + ->custom(fn(Request $request) => $request->withHeader('X-Custom-Auth', 'custom')) + ->authentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('custom', $request->getHeaderLine('X-Custom-Auth')); + } +} From 25f2a52c54de9a21ec46482b6ac583dd39eafe76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 17:16:06 +0100 Subject: [PATCH 15/88] fix(v3): validate custom auth callbacks --- docs/authentication.md | 1 + src/Authentication/CallbackAuthentication.php | 8 +++++++- tests/Unit/Builder/AuthBuilderTest.php | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/authentication.md b/docs/authentication.md index fae7f8c..9543feb 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -77,3 +77,4 @@ $this->auth()->custom(function (RequestInterface $request): RequestInterface { ``` The callback receives the outgoing PSR request and must return a PSR request. +Returning anything else throws an `UnexpectedValueException`. diff --git a/src/Authentication/CallbackAuthentication.php b/src/Authentication/CallbackAuthentication.php index f826a7c..9295877 100644 --- a/src/Authentication/CallbackAuthentication.php +++ b/src/Authentication/CallbackAuthentication.php @@ -16,6 +16,12 @@ public function __construct( public function authenticate(RequestInterface $request) { - return ($this->callback)($request); + $authenticatedRequest = ($this->callback)($request); + + if (! $authenticatedRequest instanceof RequestInterface) { + throw new \UnexpectedValueException('Custom authentication callback must return a PSR-7 request.'); + } + + return $authenticatedRequest; } } diff --git a/tests/Unit/Builder/AuthBuilderTest.php b/tests/Unit/Builder/AuthBuilderTest.php index 8e751e6..5167ba5 100644 --- a/tests/Unit/Builder/AuthBuilderTest.php +++ b/tests/Unit/Builder/AuthBuilderTest.php @@ -62,4 +62,16 @@ public function testCustomAuthenticationUsesCallback(): void $this->assertSame('custom', $request->getHeaderLine('X-Custom-Auth')); } + + public function testCustomAuthenticationCallbackMustReturnRequest(): void + { + $authentication = (new AuthBuilder()) + ->custom(fn() => null) + ->authentication(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Custom authentication callback must return a PSR-7 request.'); + + $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + } } From 76c93f45ab7d5b6905fb48d3c72c68238068b55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 17:34:40 +0100 Subject: [PATCH 16/88] feat(v3): add fluent plugin configuration --- docs/api-reference.md | 1 + docs/api.md | 12 ++++ docs/index.md | 1 + docs/plugins.md | 50 ++++++++++++++ src/Api.php | 34 ++++++--- src/Builder/ClientBuilder.php | 25 ++++--- src/Builder/PluginBuilder.php | 53 +++++++++++++++ tests/Fixture/JsonApi.php | 8 +++ tests/Integration/PluginTest.php | 87 ++++++++++++++++++++++++ tests/Unit/Builder/ClientBuilderTest.php | 26 +++---- tests/Unit/Builder/PluginBuilderTest.php | 53 +++++++++++++++ 11 files changed, 314 insertions(+), 36 deletions(-) create mode 100644 docs/plugins.md create mode 100644 src/Builder/PluginBuilder.php create mode 100644 tests/Integration/PluginTest.php create mode 100644 tests/Unit/Builder/PluginBuilderTest.php diff --git a/docs/api-reference.md b/docs/api-reference.md index fee4d61..3de5794 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,5 +4,6 @@ This reference is split by where methods are available. - [API](api.md): `Api` setup methods and `Config`. - [Authentication](authentication.md): `AuthBuilder` helpers and custom authentication. +- [Plugins](plugins.md): `PluginBuilder` helpers and internal plugin order. - [Resources](resources.md): resource modifiers and protected request helpers. - [Responses](responses.md): `Response`, `Entity`, `ResponseEnvelope`, and `Context`. diff --git a/docs/api.md b/docs/api.md index c83912b..a5672ae 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,18 @@ Authentication is applied automatically to outgoing requests. See [Authentication](authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. +## `plugins(): PluginBuilder` + +Public access to HTTPlug plugin configuration. + +```php +$api->plugins()->add($plugin, priority: 16); +``` + +Higher priority plugins run earlier. Same-priority plugins are preserved in insertion order. + +See [Plugins](plugins.md) for internal plugin order and priority guidance. + ## `responses(): ResponseBuilder` Protected access to response decoding configuration. diff --git a/docs/index.md b/docs/index.md index 780cac7..d8152ac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,5 +24,6 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. - [Authentication](authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [Plugins](plugins.md): configure HTTPlug middleware and priority ordering. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. - [API Reference](api-reference.md): current v3 authoring methods and contracts. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..d3e26c5 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,50 @@ +# Plugins + +Plugins are HTTPlug middleware applied to outgoing requests. + +SDK authors can configure plugins from the `Api` class: + +```php +use Http\Client\Common\Plugin; + +$this->plugins()->add($plugin, priority: 16); +``` + +SDK users can also add plugins to a concrete API instance: + +```php +$api->plugins()->add($retryPlugin, priority: 20); +``` + +Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. + +## Internal Plugin Order + +The package adds internal plugins with these priorities: + +| Priority | Plugin | +| --- | --- | +| `40` | Content type | +| `32` | Content length | +| `24` | Authentication | +| `16` | Cache | +| `8` | Logger | + +Custom plugins use the same priority system, so they can run before, between, or after internal plugins. + +```php +$this->plugins()->add($plugin, priority: 48); // before content type +$this->plugins()->add($plugin, priority: 20); // between auth and cache +$this->plugins()->add($plugin, priority: 0); // after logger +``` + +## Same Priority + +Same-priority plugins do not overwrite each other. + +```php +$this->plugins()->add($first, priority: 16); +$this->plugins()->add($second, priority: 16); +``` + +The request reaches `$first` before `$second`. diff --git a/src/Api.php b/src/Api.php index f91d409..34f1cf0 100644 --- a/src/Api.php +++ b/src/Api.php @@ -13,6 +13,7 @@ use ProgrammatorDev\Api\Builder\ErrorBuilder; use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener; use ProgrammatorDev\Api\Builder\LoggerBuilder; +use ProgrammatorDev\Api\Builder\PluginBuilder; use ProgrammatorDev\Api\Builder\ResponseBuilder; use ProgrammatorDev\Api\Context\ErrorContext; use ProgrammatorDev\Api\Event\PostRequestEvent; @@ -44,6 +45,8 @@ class Api private AuthBuilder $authBuilder; + private PluginBuilder $pluginBuilder; + private ResponseBuilder $responseBuilder; private ErrorBuilder $errorBuilder; @@ -55,6 +58,7 @@ public function __construct() $this->config = new Config(); $this->clientBuilder ??= new ClientBuilder(); $this->authBuilder = new AuthBuilder(); + $this->pluginBuilder = new PluginBuilder(); $this->responseBuilder = new ResponseBuilder(); $this->errorBuilder = new ErrorBuilder(); $this->eventDispatcher = new EventDispatcher(); @@ -166,6 +170,11 @@ protected function auth(): AuthBuilder return $this->authBuilder; } + public function plugins(): PluginBuilder + { + return $this->pluginBuilder; + } + public function config(?array $values = null): Config { if ($values !== null) { @@ -175,23 +184,25 @@ public function config(?array $values = null): Config return $this->config; } - private function configurePlugins(): void + private function buildPlugins(): array { + $plugins = new PluginBuilder(); + // https://docs.php-http.org/en/latest/plugins/content-type.html - $this->clientBuilder->addPlugin( + $plugins->add( plugin: new ContentTypePlugin(), priority: 40 ); // https://docs.php-http.org/en/latest/plugins/content-length.html - $this->clientBuilder->addPlugin( + $plugins->add( plugin: new ContentLengthPlugin(), priority: 32 ); // https://docs.php-http.org/en/latest/message/authentication.html if ($authentication = $this->authBuilder->authentication()) { - $this->clientBuilder->addPlugin( + $plugins->add( plugin: new AuthenticationPlugin($authentication), priority: 24 ); @@ -210,7 +221,7 @@ private function configurePlugins(): void $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); } - $this->clientBuilder->addPlugin( + $plugins->add( plugin: new CachePlugin( $this->cacheBuilder->getPool(), $this->clientBuilder->getStreamFactory(), @@ -222,7 +233,7 @@ private function configurePlugins(): void // https://docs.php-http.org/en/latest/plugins/logger.html if ($this->loggerBuilder) { - $this->clientBuilder->addPlugin( + $plugins->add( plugin: new LoggerPlugin( $this->loggerBuilder->getLogger(), $this->loggerBuilder->getFormatter() @@ -230,6 +241,12 @@ private function configurePlugins(): void priority: 8 ); } + + $plugins + ->merge($this->clientBuilder->getPluginBuilder()) + ->merge($this->pluginBuilder); + + return $plugins->all(); } public function getBaseUrl(): ?string @@ -398,8 +415,6 @@ private function sendRequest( string|StreamInterface|null $body = null ): ResponseInterface { - $this->configurePlugins(); - if (!empty($this->queryDefaults)) { $query = array_merge($this->queryDefaults, $query); } @@ -410,12 +425,13 @@ private function sendRequest( $url = $this->buildUrl($path, $query); $request = $this->createRequest($method, $url, $headers, $body); + $plugins = $this->buildPlugins(); // pre request listener $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); // request - $response = $this->clientBuilder->getClient()->sendRequest($request); + $response = $this->clientBuilder->getClient($plugins)->sendRequest($request); // post request listener return $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse(); diff --git a/src/Builder/ClientBuilder.php b/src/Builder/ClientBuilder.php index 9855ffd..cb4c720 100644 --- a/src/Builder/ClientBuilder.php +++ b/src/Builder/ClientBuilder.php @@ -7,15 +7,13 @@ use Http\Client\Common\PluginClientFactory; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; -use ProgrammatorDev\Api\Exception\PluginException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; class ClientBuilder { - /** @var Plugin[] */ - private array $plugins = []; + private PluginBuilder $pluginBuilder; public function __construct( private ?ClientInterface $client = null, @@ -26,12 +24,16 @@ public function __construct( $this->client ??= Psr18ClientDiscovery::find(); $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); + $this->pluginBuilder = new PluginBuilder(); } - public function getClient(): HttpMethodsClient + /** + * @param list|null $plugins + */ + public function getClient(?array $plugins = null): HttpMethodsClient { $pluginClientFactory = new PluginClientFactory(); - $client = $pluginClientFactory->createClient($this->client, $this->plugins); + $client = $pluginClientFactory->createClient($this->client, $plugins ?? $this->getPlugins()); return new HttpMethodsClient( $client, @@ -73,15 +75,18 @@ public function setStreamFactory(StreamFactoryInterface $streamFactory): self public function addPlugin(Plugin $plugin, int $priority): self { - $this->plugins[$priority] = $plugin; - // sort plugins by priority (key) in descending order - krsort($this->plugins); + $this->pluginBuilder->add($plugin, $priority); return $this; } public function getPlugins(): array { - return $this->plugins; + return $this->pluginBuilder->all(); } -} \ No newline at end of file + + public function getPluginBuilder(): PluginBuilder + { + return $this->pluginBuilder; + } +} diff --git a/src/Builder/PluginBuilder.php b/src/Builder/PluginBuilder.php new file mode 100644 index 0000000..b496771 --- /dev/null +++ b/src/Builder/PluginBuilder.php @@ -0,0 +1,53 @@ +> */ + private array $plugins = []; + + public function add(Plugin $plugin, int $priority = 0): self + { + $this->plugins[$priority] ??= []; + $this->plugins[$priority][] = $plugin; + + return $this; + } + + public function merge(self $builder): self + { + foreach ($builder->entries() as $priority => $plugins) { + foreach ($plugins as $plugin) { + $this->add($plugin, $priority); + } + } + + return $this; + } + + /** + * @return array> + */ + public function entries(): array + { + return $this->plugins; + } + + /** + * @return list + */ + public function all(): array + { + if ($this->plugins === []) { + return []; + } + + $plugins = $this->plugins; + krsort($plugins); + + return array_values(array_merge(...array_values($plugins))); + } +} diff --git a/tests/Fixture/JsonApi.php b/tests/Fixture/JsonApi.php index aca9a05..371462d 100644 --- a/tests/Fixture/JsonApi.php +++ b/tests/Fixture/JsonApi.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\Api\Test\Fixture; use Http\Mock\Client; +use Http\Client\Common\Plugin; use Http\Message\Authentication\Header as HeaderAuthentication; use ProgrammatorDev\Api\Api; use ProgrammatorDev\Api\Builder\ClientBuilder; @@ -106,6 +107,13 @@ public function useCustomAuth(string $headerName, string $headerValue): self return $this; } + public function usePlugin(Plugin $plugin, int $priority = 0): self + { + $this->plugins()->add($plugin, $priority); + + return $this; + } + public function raw(): RawResource { return $this->resource(RawResource::class); diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php new file mode 100644 index 0000000..0738190 --- /dev/null +++ b/tests/Integration/PluginTest.php @@ -0,0 +1,87 @@ +client(responses: 1); + + (new JsonApi($client)) + ->usePlugin($this->headerPlugin('low'), priority: 8) + ->usePlugin($this->headerPlugin('high'), priority: 40) + ->usePlugin($this->headerPlugin('middle'), priority: 16) + ->raw() + ->fetch(); + + $this->assertSame(['high', 'middle', 'low'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + + public function testSdkUserCanConfigurePlugins(): void + { + $client = $this->client(responses: 1); + $api = new JsonApi($client); + + $api->plugins()->add($this->headerPlugin('user'), priority: 20); + $api->raw()->fetch(); + + $this->assertSame(['user'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + + public function testConfiguredPluginsWithSamePriorityAreAppliedInInsertionOrder(): void + { + $client = $this->client(responses: 1); + + (new JsonApi($client)) + ->usePlugin($this->headerPlugin('first'), priority: 16) + ->usePlugin($this->headerPlugin('second'), priority: 16) + ->raw() + ->fetch(); + + $this->assertSame(['first', 'second'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + + public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void + { + $client = $this->client(responses: 2); + $api = (new JsonApi($client))->usePlugin($this->headerPlugin('once'), priority: 16); + + $api->raw()->fetch(); + $api->raw()->fetch(); + + $this->assertSame(['once'], $client->getRequests()[0]->getHeader('X-Plugin-Order')); + $this->assertSame(['once'], $client->getRequests()[1]->getHeader('X-Plugin-Order')); + } + + private function headerPlugin(string $value): Plugin + { + return new class($value) implements Plugin { + public function __construct(private readonly string $value) {} + + public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise + { + return $next($request->withAddedHeader('X-Plugin-Order', $this->value)); + } + }; + } + + private function client(int $responses): Client + { + $client = new Client(); + + for ($i = 0; $i < $responses; $i++) { + $client->addResponse(new Response(body: '{}')); + } + + return $client; + } +} diff --git a/tests/Unit/Builder/ClientBuilderTest.php b/tests/Unit/Builder/ClientBuilderTest.php index d51dc79..dd0b8f8 100644 --- a/tests/Unit/Builder/ClientBuilderTest.php +++ b/tests/Unit/Builder/ClientBuilderTest.php @@ -4,7 +4,6 @@ use Http\Client\Common\Plugin; use ProgrammatorDev\Api\Builder\ClientBuilder; -use ProgrammatorDev\Api\Exception\PluginException; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -52,24 +51,17 @@ public function testSetters() public function testAddPlugin() { - $plugin = $this->createMock(Plugin::class); + $low = $this->createMock(Plugin::class); + $high = $this->createMock(Plugin::class); + $middle = $this->createMock(Plugin::class); $clientBuilder = new ClientBuilder(); - $clientBuilder->addPlugin($plugin, 1); - $clientBuilder->addPlugin($plugin, 3); - $clientBuilder->addPlugin($plugin, 2); + $clientBuilder->addPlugin($low, 1); + $clientBuilder->addPlugin($high, 3); + $clientBuilder->addPlugin($middle, 2); $this->assertCount(3, $clientBuilder->getPlugins()); - // plugins array keys are used as priority [priority => plugin] - // so check if the order of keys (priority) is sorted - $this->assertSame( - [ - 0 => 3, - 1 => 2, - 2 => 1 - ], - array_keys($clientBuilder->getPlugins()) - ); + $this->assertSame([$high, $middle, $low], $clientBuilder->getPlugins()); } public function testAddPluginWithSamePriority() @@ -80,6 +72,6 @@ public function testAddPluginWithSamePriority() $clientBuilder->addPlugin($plugin, 1); $clientBuilder->addPlugin($plugin, 1); - $this->assertCount(1, $clientBuilder->getPlugins()); + $this->assertCount(2, $clientBuilder->getPlugins()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Builder/PluginBuilderTest.php b/tests/Unit/Builder/PluginBuilderTest.php new file mode 100644 index 0000000..59778fa --- /dev/null +++ b/tests/Unit/Builder/PluginBuilderTest.php @@ -0,0 +1,53 @@ +createMock(Plugin::class); + $high = $this->createMock(Plugin::class); + $middle = $this->createMock(Plugin::class); + + $plugins = (new PluginBuilder()) + ->add($low, priority: 8) + ->add($high, priority: 40) + ->add($middle, priority: 16) + ->all(); + + $this->assertSame([$high, $middle, $low], $plugins); + } + + public function testPluginsWithSamePriorityArePreservedInInsertionOrder(): void + { + $first = $this->createMock(Plugin::class); + $second = $this->createMock(Plugin::class); + + $plugins = (new PluginBuilder()) + ->add($first, priority: 16) + ->add($second, priority: 16) + ->all(); + + $this->assertSame([$first, $second], $plugins); + } + + public function testPluginBuildersCanBeMergedWithoutLosingPriority(): void + { + $low = $this->createMock(Plugin::class); + $high = $this->createMock(Plugin::class); + + $source = (new PluginBuilder())->add($high, priority: 40); + + $plugins = (new PluginBuilder()) + ->add($low, priority: 8) + ->merge($source) + ->all(); + + $this->assertSame([$high, $low], $plugins); + } +} From bc040afb4b80e5c2ea26b501048ae1ca74047acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 17:43:55 +0100 Subject: [PATCH 17/88] docs(v3): clarify plugin priorities --- docs/authentication.md | 6 +++--- docs/plugins.md | 26 ++++++++++++++++++-------- src/Api.php | 10 +++++----- tests/Integration/PluginTest.php | 31 +++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 9543feb..90eff99 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -32,7 +32,7 @@ $this->auth() ->query('appid', $apiKey); ``` -Internally, multiple authentication rules become an HTTPlug authentication chain. +Internally, multiple authentication rules become an [HTTPlug](https://httplug.io/) authentication chain. ## Query Authentication @@ -50,7 +50,7 @@ $this->queryDefaults(['units' => 'metric']); ## HTTPlug Authentication Objects -Use `chain()` when an SDK needs to reuse specific HTTPlug authentication implementations: +Use `chain()` when an SDK needs to reuse specific [HTTPlug authentication implementations](https://docs.php-http.org/en/latest/message/authentication.html): ```php use Http\Message\Authentication\Bearer; @@ -62,7 +62,7 @@ $this->auth()->chain( ); ``` -This is mostly useful when an SDK author already has an `Http\Message\Authentication` object or needs behavior provided by `php-http/message`. +This is mostly useful when an SDK author already has an `Http\Message\Authentication` object or needs behavior provided by [`php-http/message`](https://docs.php-http.org/en/latest/message/index.html). ## Custom Authentication diff --git a/docs/plugins.md b/docs/plugins.md index d3e26c5..d02f11d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,6 +1,8 @@ # Plugins -Plugins are HTTPlug middleware applied to outgoing requests. +Plugins are [HTTPlug](https://httplug.io/) middleware applied to outgoing requests. + +See the [PHP-HTTP plugin documentation](https://docs.php-http.org/en/latest/plugins/index.html) for the underlying plugin system used here. SDK authors can configure plugins from the `Api` class: @@ -24,17 +26,25 @@ The package adds internal plugins with these priorities: | Priority | Plugin | | --- | --- | -| `40` | Content type | -| `32` | Content length | -| `24` | Authentication | -| `16` | Cache | -| `8` | Logger | +| `50` | Content type | +| `40` | Content length | +| `30` | Authentication | +| `20` | Cache | +| `10` | Logger | + +## What Internal Plugins Do + +- [Content type](https://docs.php-http.org/en/latest/plugins/content-type.html): sets a `Content-Type` header when the request body makes it inferable and the header is not already set. +- [Content length](https://docs.php-http.org/en/latest/plugins/content-length.html): sets request body length metadata before the request is sent. +- [Authentication](https://docs.php-http.org/en/latest/plugins/authentication.html): applies credentials configured through `auth()`. +- [Cache](https://docs.php-http.org/en/latest/plugins/cache.html): reads and writes cacheable responses through PSR-6 cache support. Cache-specific logging is handled through cache listeners when logging is configured. +- [Logger](https://docs.php-http.org/en/latest/plugins/logger.html): logs HTTP requests and responses through the configured PSR-3 logger. Custom plugins use the same priority system, so they can run before, between, or after internal plugins. ```php -$this->plugins()->add($plugin, priority: 48); // before content type -$this->plugins()->add($plugin, priority: 20); // between auth and cache +$this->plugins()->add($plugin, priority: 60); // before content type +$this->plugins()->add($plugin, priority: 25); // between auth and cache $this->plugins()->add($plugin, priority: 0); // after logger ``` diff --git a/src/Api.php b/src/Api.php index 34f1cf0..f8bb20a 100644 --- a/src/Api.php +++ b/src/Api.php @@ -191,20 +191,20 @@ private function buildPlugins(): array // https://docs.php-http.org/en/latest/plugins/content-type.html $plugins->add( plugin: new ContentTypePlugin(), - priority: 40 + priority: 50 ); // https://docs.php-http.org/en/latest/plugins/content-length.html $plugins->add( plugin: new ContentLengthPlugin(), - priority: 32 + priority: 40 ); // https://docs.php-http.org/en/latest/message/authentication.html if ($authentication = $this->authBuilder->authentication()) { $plugins->add( plugin: new AuthenticationPlugin($authentication), - priority: 24 + priority: 30 ); } @@ -227,7 +227,7 @@ private function buildPlugins(): array $this->clientBuilder->getStreamFactory(), $cacheOptions ), - priority: 16 + priority: 20 ); } @@ -238,7 +238,7 @@ private function buildPlugins(): array $this->loggerBuilder->getLogger(), $this->loggerBuilder->getFormatter() ), - priority: 8 + priority: 10 ); } diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php index 0738190..0a9186e 100644 --- a/tests/Integration/PluginTest.php +++ b/tests/Integration/PluginTest.php @@ -50,6 +50,23 @@ public function testConfiguredPluginsWithSamePriorityAreAppliedInInsertionOrder( $this->assertSame(['first', 'second'], $client->getLastRequest()->getHeader('X-Plugin-Order')); } + public function testPluginPriorityCanTargetInternalAuthOrder(): void + { + $client = $this->client(responses: 1); + + (new JsonApi($client)) + ->useBearerAuth('secret') + ->usePlugin($this->authStatePlugin('before-auth'), priority: 35) + ->usePlugin($this->authStatePlugin('after-auth'), priority: 25) + ->raw() + ->fetch(); + + $this->assertSame( + ['before-auth:missing', 'after-auth:present'], + $client->getLastRequest()->getHeader('X-Auth-State') + ); + } + public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void { $client = $this->client(responses: 2); @@ -74,6 +91,20 @@ public function handleRequest(RequestInterface $request, callable $next, callabl }; } + private function authStatePlugin(string $label): Plugin + { + return new class($label) implements Plugin { + public function __construct(private readonly string $label) {} + + public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise + { + $state = $request->hasHeader('Authorization') ? 'present' : 'missing'; + + return $next($request->withAddedHeader('X-Auth-State', sprintf('%s:%s', $this->label, $state))); + } + }; + } + private function client(int $responses): Client { $client = new Client(); From 5c1366f5ffb64074f390194fc031da80a3e5669c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 17:53:00 +0100 Subject: [PATCH 18/88] feat(v3): add wsse and conditional auth --- docs/authentication.md | 21 ++++++++++++++ src/Builder/AuthBuilder.php | 13 +++++++++ tests/Fixture/JsonApi.php | 18 ++++++++++++ tests/Fixture/RawResource.php | 5 ++++ tests/Integration/AuthenticationTest.php | 37 ++++++++++++++++++++++++ tests/Unit/Builder/AuthBuilderTest.php | 26 +++++++++++++++++ 6 files changed, 120 insertions(+) diff --git a/docs/authentication.md b/docs/authentication.md index 90eff99..8556949 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -24,6 +24,11 @@ $this->auth()->header('X-Api-Key', $apiKey); $this->auth()->query('appid', $apiKey); ``` +```php +$this->auth()->wsse($username, $password); +$this->auth()->wsse($username, $password, hashAlgorithm: 'sha512'); +``` + Multiple calls are chained in order: ```php @@ -64,6 +69,22 @@ $this->auth()->chain( This is mostly useful when an SDK author already has an `Http\Message\Authentication` object or needs behavior provided by [`php-http/message`](https://docs.php-http.org/en/latest/message/index.html). +## Conditional Authentication + +Use `conditional()` when authentication should only apply to matching requests. + +```php +use Http\Message\Authentication\Bearer; +use Http\Message\RequestMatcher\RequestMatcher; + +$this->auth()->conditional( + new RequestMatcher(path: '^/admin'), + new Bearer($adminToken), +); +``` + +`conditional()` uses PHP-HTTP's `RequestConditional` authentication internally. + ## Custom Authentication Use `custom()` for request-mutating authentication logic: diff --git a/src/Builder/AuthBuilder.php b/src/Builder/AuthBuilder.php index 12e2801..4d39ce9 100644 --- a/src/Builder/AuthBuilder.php +++ b/src/Builder/AuthBuilder.php @@ -8,6 +8,9 @@ use Http\Message\Authentication\Chain; use Http\Message\Authentication\Header; use Http\Message\Authentication\QueryParam; +use Http\Message\Authentication\RequestConditional; +use Http\Message\Authentication\Wsse; +use Http\Message\RequestMatcher; use ProgrammatorDev\Api\Authentication\CallbackAuthentication; use Psr\Http\Message\RequestInterface; @@ -36,6 +39,16 @@ public function query(string $name, mixed $value): self return $this->chain(new QueryParam([$name => $value])); } + public function wsse(string $username, string $password, string $hashAlgorithm = 'sha1'): self + { + return $this->chain(new Wsse($username, $password, $hashAlgorithm)); + } + + public function conditional(RequestMatcher $matcher, Authentication $authentication): self + { + return $this->chain(new RequestConditional($matcher, $authentication)); + } + public function chain(Authentication ...$authentications): self { foreach ($authentications as $authentication) { diff --git a/tests/Fixture/JsonApi.php b/tests/Fixture/JsonApi.php index 371462d..a06a09b 100644 --- a/tests/Fixture/JsonApi.php +++ b/tests/Fixture/JsonApi.php @@ -5,6 +5,7 @@ use Http\Mock\Client; use Http\Client\Common\Plugin; use Http\Message\Authentication\Header as HeaderAuthentication; +use Http\Message\RequestMatcher\RequestMatcher; use ProgrammatorDev\Api\Api; use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Context\ErrorContext; @@ -91,6 +92,23 @@ public function useQueryAuth(string $name, string $value): self return $this; } + public function useWsseAuth(string $username, string $password): self + { + $this->auth()->wsse($username, $password); + + return $this; + } + + public function useConditionalAuth(): self + { + $this->auth()->conditional( + new RequestMatcher(path: '^/raw'), + new HeaderAuthentication('X-Conditional-Auth', 'conditional') + ); + + return $this; + } + public function useChainedAuth(string $headerName, string $headerValue): self { $this->auth()->chain(new HeaderAuthentication($headerName, $headerValue)); diff --git a/tests/Fixture/RawResource.php b/tests/Fixture/RawResource.php index 8ec3d32..a3ba75e 100644 --- a/tests/Fixture/RawResource.php +++ b/tests/Fixture/RawResource.php @@ -11,4 +11,9 @@ public function fetch(): Response { return $this->get('/raw'); } + + public function absolute(string $url): Response + { + return $this->get($url); + } } diff --git a/tests/Integration/AuthenticationTest.php b/tests/Integration/AuthenticationTest.php index eb648ae..e3961c1 100644 --- a/tests/Integration/AuthenticationTest.php +++ b/tests/Integration/AuthenticationTest.php @@ -59,6 +59,43 @@ public function testQueryAuthenticationAddsConfiguredQueryParameter(): void $this->assertSame('secret', $query['appid']); } + public function testWsseAuthenticationAddsWsseHeaders(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useWsseAuth('user', 'pass') + ->raw() + ->fetch(); + + $this->assertSame('WSSE profile="UsernameToken"', $client->getLastRequest()->getHeaderLine('Authorization')); + $this->assertStringContainsString('UsernameToken Username="user"', $client->getLastRequest()->getHeaderLine('X-WSSE')); + } + + public function testConditionalAuthenticationAddsAuthenticationWhenMatched(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useConditionalAuth() + ->raw() + ->fetch(); + + $this->assertSame('conditional', $client->getLastRequest()->getHeaderLine('X-Conditional-Auth')); + } + + public function testConditionalAuthenticationDoesNotAddAuthenticationWhenUnmatched(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useConditionalAuth() + ->raw() + ->absolute('https://api.example.com/users'); + + $this->assertSame('', $client->getLastRequest()->getHeaderLine('X-Conditional-Auth')); + } + public function testChainedAuthenticationCanBeUsed(): void { $client = $this->client(); diff --git a/tests/Unit/Builder/AuthBuilderTest.php b/tests/Unit/Builder/AuthBuilderTest.php index 5167ba5..183d805 100644 --- a/tests/Unit/Builder/AuthBuilderTest.php +++ b/tests/Unit/Builder/AuthBuilderTest.php @@ -4,6 +4,7 @@ use Http\Message\Authentication\Chain; use Http\Message\Authentication\Header; +use Http\Message\RequestMatcher\RequestMatcher; use Nyholm\Psr7\Request; use ProgrammatorDev\Api\Builder\AuthBuilder; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; @@ -52,6 +53,31 @@ public function testExplicitChainAcceptsHttplugAuthenticationObjects(): void $this->assertSame('secret', $request->getHeaderLine('X-Api-Key')); } + public function testWsseAuthenticationAddsWsseHeaders(): void + { + $authentication = (new AuthBuilder()) + ->wsse('user', 'pass') + ->authentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('WSSE profile="UsernameToken"', $request->getHeaderLine('Authorization')); + $this->assertStringContainsString('UsernameToken Username="user"', $request->getHeaderLine('X-WSSE')); + } + + public function testConditionalAuthenticationOnlyAppliesWhenMatched(): void + { + $authentication = (new AuthBuilder()) + ->conditional(new RequestMatcher(path: '^/admin'), new Header('X-Admin-Auth', 'secret')) + ->authentication(); + + $matched = $authentication->authenticate(new Request('GET', 'https://api.example.com/admin/users')); + $unmatched = $authentication->authenticate(new Request('GET', 'https://api.example.com/users')); + + $this->assertSame('secret', $matched->getHeaderLine('X-Admin-Auth')); + $this->assertSame('', $unmatched->getHeaderLine('X-Admin-Auth')); + } + public function testCustomAuthenticationUsesCallback(): void { $authentication = (new AuthBuilder()) From cf0ad7dc96a24052de67f86b922a3cab5a2df018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 17:56:01 +0100 Subject: [PATCH 19/88] docs(v3): update auth plugin checklist --- docs/v3-architecture-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index f17ebcc..85172a9 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -512,8 +512,8 @@ Before tagging v3: - [x] PSR-17 stream factory support. - [ ] PSR-6 cache support. - [ ] PSR-3 logger support. -- [ ] Authentication support. -- [ ] Plugin support. +- [x] Authentication support. +- [x] Plugin support. - [ ] Request hooks. - [ ] Response hooks. - [ ] Response content transformation. From 23dfcd6bb9a9b1497d76ccd78f0e9012e62fb1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 18:31:29 +0100 Subject: [PATCH 20/88] feat(v3): add fluent cache configuration --- docs/api-reference.md | 1 + docs/api.md | 13 +++++++ docs/cache.md | 48 +++++++++++++++++++++++++ docs/index.md | 1 + docs/v3-architecture-plan.md | 21 +++++++++++ src/Api.php | 22 +++++------- src/Builder/CacheBuilder.php | 18 +++++----- tests/Integration/CacheTest.php | 33 +++++++++++++++++ tests/Unit/Builder/CacheBuilderTest.php | 26 +++++++------- 9 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 docs/cache.md create mode 100644 tests/Integration/CacheTest.php diff --git a/docs/api-reference.md b/docs/api-reference.md index 3de5794..df50d38 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,6 +4,7 @@ This reference is split by where methods are available. - [API](api.md): `Api` setup methods and `Config`. - [Authentication](authentication.md): `AuthBuilder` helpers and custom authentication. +- [Cache](cache.md): `CacheBuilder` helpers and PSR-6 cache configuration. - [Plugins](plugins.md): `PluginBuilder` helpers and internal plugin order. - [Resources](resources.md): resource modifiers and protected request helpers. - [Responses](responses.md): `Response`, `Entity`, `ResponseEnvelope`, and `Context`. diff --git a/docs/api.md b/docs/api.md index a5672ae..7ca8a1c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -97,6 +97,19 @@ Higher priority plugins run earlier. Same-priority plugins are preserved in inse See [Plugins](plugins.md) for internal plugin order and priority guidance. +## `cache(CacheItemPoolInterface $pool): CacheBuilder` + +Public access to PSR-6 HTTP response cache configuration. + +```php +$api + ->cache($pool) + ->defaultTtl(3600) + ->methods(['GET', 'HEAD']); +``` + +See [Cache](cache.md) for cache options and plugin order. + ## `responses(): ResponseBuilder` Protected access to response decoding configuration. diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..908f80b --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,48 @@ +# Cache + +Cache support uses the [PHP-HTTP cache plugin](https://docs.php-http.org/en/latest/plugins/cache.html) with a PSR-6 cache pool. + +SDK users can configure cache on an API instance: + +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +$api + ->cache(new FilesystemAdapter()) + ->defaultTtl(3600); +``` + +SDK authors can also configure cache from the `Api` class: + +```php +$this + ->cache($pool) + ->defaultTtl(3600) + ->methods(['GET', 'HEAD']); +``` + +## Options + +```php +$api->cache($pool)->defaultTtl(3600); +``` + +Sets the fallback cache TTL in seconds when the response does not provide cache directives. Use `null` to let the cache backend store as long as it can. + +```php +$api->cache($pool)->methods(['GET', 'HEAD']); +``` + +Sets which request methods can be cached. + +```php +$api->cache($pool)->responseCacheDirectives(['max-age']); +``` + +Sets the response cache directives respected by the cache plugin. + +## Internal Order + +The cache plugin runs at priority `20`, after authentication and before the logger plugin. + +When logging is configured, cache hit/miss/write events are logged through the cache plugin listener. diff --git a/docs/index.md b/docs/index.md index d8152ac..29decde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. - [Authentication](authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [Cache](cache.md): configure PSR-6 HTTP response caching. - [Plugins](plugins.md): configure HTTPlug middleware and priority ordering. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. - [API Reference](api-reference.md): current v3 authoring methods and contracts. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 85172a9..7eef1e8 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -102,6 +102,25 @@ Query merge order: global API defaults < resource options < endpoint-specific options ``` +Builder-backed features can follow the same shape when endpoint-specific behavior is useful. + +API-level builders configure global defaults: + +```php +$api->cache($pool)->defaultTtl(3600); +``` + +Request-local overrides should live on the pending request/resource flow instead of mutating the API-level builder: + +```php +return $this + ->get('/weather') + ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(300)) + ->entity(CurrentWeather::class); +``` + +This keeps one SDK instance safe to reuse while still letting SDK authors expose endpoint-specific cache, logger, auth, plugin, or hook behavior where it makes sense. Do this one builder area at a time, starting only when there is a concrete feature need. + Resource constructors may remain public. SDK authors should usually expose resources through `Api::resource()`, but direct construction is useful for testing and advanced use. ### `Response` @@ -355,6 +374,8 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::as()`. - No reset methods for resource options in the first phase. - Merge order should be global defaults, then resource options, then endpoint-specific options. +- Builder-backed features should follow the same default/override rule where useful: API builders define global defaults, and request-local builder overrides belong in `RequestOptions` or the pending request flow. +- Request-local builder overrides should not mutate the API-level builders. - Header names should not be normalized manually. - Path parameters should be encoded with `rawurlencode`. - Query strings should use `http_build_query(..., PHP_QUERY_RFC3986)`. diff --git a/src/Api.php b/src/Api.php index f8bb20a..502f3ed 100644 --- a/src/Api.php +++ b/src/Api.php @@ -22,6 +22,7 @@ use ProgrammatorDev\Api\Helper\StringHelper; use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface as ClientException; +use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; @@ -175,6 +176,13 @@ public function plugins(): PluginBuilder return $this->pluginBuilder; } + public function cache(CacheItemPoolInterface $pool): CacheBuilder + { + $this->cacheBuilder = new CacheBuilder($pool); + + return $this->cacheBuilder; + } + public function config(?array $values = null): Config { if ($values !== null) { @@ -211,7 +219,7 @@ private function buildPlugins(): array // https://docs.php-http.org/en/latest/plugins/cache.html if ($this->cacheBuilder) { $cacheOptions = [ - 'default_ttl' => $this->cacheBuilder->getTtl(), + 'default_ttl' => $this->cacheBuilder->getDefaultTtl(), 'methods' => $this->cacheBuilder->getMethods(), 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), 'cache_listeners' => [] @@ -311,18 +319,6 @@ public function setClientBuilder(ClientBuilder $clientBuilder): self return $this; } - public function getCacheBuilder(): ?CacheBuilder - { - return $this->cacheBuilder; - } - - public function setCacheBuilder(?CacheBuilder $cacheBuilder): self - { - $this->cacheBuilder = $cacheBuilder; - - return $this; - } - public function getLoggerBuilder(): ?LoggerBuilder { return $this->loggerBuilder; diff --git a/src/Builder/CacheBuilder.php b/src/Builder/CacheBuilder.php index ce83039..f49a5b9 100644 --- a/src/Builder/CacheBuilder.php +++ b/src/Builder/CacheBuilder.php @@ -9,7 +9,7 @@ class CacheBuilder { public function __construct( private CacheItemPoolInterface $pool, - private ?int $ttl = 60, + private ?int $defaultTtl = 60, private array $methods = [Method::GET, Method::HEAD], private array $responseCacheDirectives = ['max-age'] ) {} @@ -19,21 +19,21 @@ public function getPool(): CacheItemPoolInterface return $this->pool; } - public function setPool(CacheItemPoolInterface $pool): self + public function pool(CacheItemPoolInterface $pool): self { $this->pool = $pool; return $this; } - public function getTtl(): ?int + public function getDefaultTtl(): ?int { - return $this->ttl; + return $this->defaultTtl; } - public function setTtl(?int $ttl): self + public function defaultTtl(?int $defaultTtl): self { - $this->ttl = $ttl; + $this->defaultTtl = $defaultTtl; return $this; } @@ -43,7 +43,7 @@ public function getMethods(): array return $this->methods; } - public function setMethods(array $methods): self + public function methods(array $methods): self { $this->methods = $methods; @@ -55,10 +55,10 @@ public function getResponseCacheDirectives(): array return $this->responseCacheDirectives; } - public function setResponseCacheDirectives(array $responseCacheDirectives): self + public function responseCacheDirectives(array $responseCacheDirectives): self { $this->responseCacheDirectives = $responseCacheDirectives; return $this; } -} \ No newline at end of file +} diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php new file mode 100644 index 0000000..4b28ef4 --- /dev/null +++ b/tests/Integration/CacheTest.php @@ -0,0 +1,33 @@ +addResponse(new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":1}' + )); + + $api = new JsonApi($client); + $api + ->cache(new ArrayAdapter()) + ->defaultTtl(60); + + $first = $api->raw()->fetch(); + $second = $api->raw()->fetch(); + + $this->assertSame(['id' => 1], $first->data()); + $this->assertSame(['id' => 1], $second->data()); + $this->assertCount(1, $client->getRequests()); + } +} diff --git a/tests/Unit/Builder/CacheBuilderTest.php b/tests/Unit/Builder/CacheBuilderTest.php index 2d2bfcc..2b8b7d6 100644 --- a/tests/Unit/Builder/CacheBuilderTest.php +++ b/tests/Unit/Builder/CacheBuilderTest.php @@ -15,7 +15,7 @@ public function testDefaults() $cacheBuilder = new CacheBuilder($pool); $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame(60, $cacheBuilder->getTtl()); + $this->assertSame(60, $cacheBuilder->getDefaultTtl()); $this->assertSame(['GET', 'HEAD'], $cacheBuilder->getMethods()); $this->assertSame(['max-age'], $cacheBuilder->getResponseCacheDirectives()); } @@ -23,34 +23,34 @@ public function testDefaults() public function testDependencyInjection() { $pool = $this->createMock(CacheItemPoolInterface::class); - $ttl = 600; + $defaultTtl = 600; $methods = ['GET']; $responseCacheDirectives = ['no-cache', 'max-age']; - $cacheBuilder = new CacheBuilder($pool, $ttl, $methods, $responseCacheDirectives); + $cacheBuilder = new CacheBuilder($pool, $defaultTtl, $methods, $responseCacheDirectives); $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame($ttl, $cacheBuilder->getTtl()); + $this->assertSame($defaultTtl, $cacheBuilder->getDefaultTtl()); $this->assertSame($methods, $cacheBuilder->getMethods()); $this->assertSame($responseCacheDirectives, $cacheBuilder->getResponseCacheDirectives()); } - public function testSetters() + public function testFluentMethods() { $pool = $this->createMock(CacheItemPoolInterface::class); - $ttl = 600; + $defaultTtl = 600; $methods = ['GET']; $responseCacheDirectives = ['no-cache', 'max-age']; - $cacheBuilder = new CacheBuilder($pool); - $cacheBuilder->setPool($pool); - $cacheBuilder->setTtl($ttl); - $cacheBuilder->setMethods($methods); - $cacheBuilder->setResponseCacheDirectives($responseCacheDirectives); + $cacheBuilder = (new CacheBuilder($pool)) + ->pool($pool) + ->defaultTtl($defaultTtl) + ->methods($methods) + ->responseCacheDirectives($responseCacheDirectives); $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame($ttl, $cacheBuilder->getTtl()); + $this->assertSame($defaultTtl, $cacheBuilder->getDefaultTtl()); $this->assertSame($methods, $cacheBuilder->getMethods()); $this->assertSame($responseCacheDirectives, $cacheBuilder->getResponseCacheDirectives()); } -} \ No newline at end of file +} From 58be2db8823c8681a3583bf50cd0028895af5b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 18:40:52 +0100 Subject: [PATCH 21/88] refactor(v3): remove legacy api accessors --- docs/v3-architecture-plan.md | 18 +++-------- src/Api.php | 62 +++--------------------------------- 2 files changed, 8 insertions(+), 72 deletions(-) diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 7eef1e8..a653038 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -200,31 +200,21 @@ Start with a minimal context API. Add richer access only when implementation nee Current public methods: - `request` -- `getBaseUrl` -- `setBaseUrl` -- `getQueryDefault` -- `addQueryDefault` -- `removeQueryDefault` -- `getHeaderDefault` -- `addHeaderDefault` -- `removeHeaderDefault` - `getClientBuilder` - `setClientBuilder` -- `getCacheBuilder` -- `setCacheBuilder` - `getLoggerBuilder` - `setLoggerBuilder` -- `getAuthentication` -- `setAuthentication` +- `plugins` +- `cache` +- `config` - `addPreRequestListener` - `addPostRequestListener` - `addResponseContentsListener` -- `buildPath` Observations: - The current `Api` class is easy to extend but exposes low-level request execution directly. -- SDK packages currently use `request` and `buildPath` from resources. +- SDK packages currently use `request` from resources. - Defaults are global to the API instance, which makes resource-level fluent options awkward. - JSON decoding and error handling are implemented through listeners. - Plugin configuration is automatic inside `request`, which can duplicate responsibilities and makes request flow harder to reason about. diff --git a/src/Api.php b/src/Api.php index 502f3ed..6152bfa 100644 --- a/src/Api.php +++ b/src/Api.php @@ -133,25 +133,21 @@ protected function resource(string $class): Resource protected function baseUrl(?string $baseUrl): static { - $this->setBaseUrl($baseUrl); + $this->baseUrl = $baseUrl; return $this; } protected function queryDefaults(array $query): static { - foreach ($query as $name => $value) { - $this->addQueryDefault($name, $value); - } + $this->queryDefaults = array_merge($this->queryDefaults, $query); return $this; } protected function headerDefaults(array $headers): static { - foreach ($headers as $name => $value) { - $this->addHeaderDefault($name, $value); - } + $this->headerDefaults = array_merge($this->headerDefaults, $headers); return $this; } @@ -257,56 +253,6 @@ private function buildPlugins(): array return $plugins->all(); } - public function getBaseUrl(): ?string - { - return $this->baseUrl; - } - - public function setBaseUrl(?string $baseUrl): self - { - $this->baseUrl = $baseUrl; - - return $this; - } - - public function getQueryDefault(string $name): mixed - { - return $this->queryDefaults[$name] ?? null; - } - - public function addQueryDefault(string $name, mixed $value): self - { - $this->queryDefaults[$name] = $value; - - return $this; - } - - public function removeQueryDefault(string $name): self - { - unset($this->queryDefaults[$name]); - - return $this; - } - - public function getHeaderDefault(string $name): mixed - { - return $this->headerDefaults[$name] ?? null; - } - - public function addHeaderDefault(string $name, mixed $value): self - { - $this->headerDefaults[$name] = $value; - - return $this; - } - - public function removeHeaderDefault(string $name): self - { - unset($this->headerDefaults[$name]); - - return $this; - } - public function getClientBuilder(): ?ClientBuilder { return $this->clientBuilder; @@ -352,7 +298,7 @@ public function addResponseContentsListener(callable $listener, int $priority = return $this; } - public function buildPath(string $path, array $parameters): string + private function buildPath(string $path, array $parameters): string { foreach ($parameters as $parameter => $value) { $path = str_replace( From 57b29e30b610555eb3c23b184c87660eb4d058ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 18:54:26 +0100 Subject: [PATCH 22/88] refactor(v3): make client configuration fluent --- docs/api.md | 17 +++++++++++ docs/v3-architecture-plan.md | 6 ++-- src/Api.php | 24 ++++++---------- src/Builder/ClientBuilder.php | 33 ++++------------------ tests/Fixture/FakeApi.php | 3 +- tests/Fixture/JsonApi.php | 3 +- tests/Fixture/PlainApi.php | 3 +- tests/Unit/Builder/ClientBuilderTest.php | 36 ++++-------------------- 8 files changed, 43 insertions(+), 82 deletions(-) diff --git a/docs/api.md b/docs/api.md index 7ca8a1c..c2c11aa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,6 +110,23 @@ $api See [Cache](cache.md) for cache options and plugin order. +## `client(ClientInterface $client): ClientBuilder` + +Public access to PSR-18 client configuration. + +```php +$api->client($client); +``` + +SDK authors can configure PSR-17 factories on the returned builder: + +```php +$this + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory); +``` + ## `responses(): ResponseBuilder` Protected access to response decoding configuration. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index a653038..2f0edce 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -200,8 +200,7 @@ Start with a minimal context API. Add richer access only when implementation nee Current public methods: - `request` -- `getClientBuilder` -- `setClientBuilder` +- `client` - `getLoggerBuilder` - `setLoggerBuilder` - `plugins` @@ -225,6 +224,7 @@ Observations: Current public builder classes: - `ClientBuilder` +- `PluginBuilder` - `CacheBuilder` - `LoggerBuilder` @@ -232,7 +232,7 @@ Current capabilities: - PSR-18 client discovery and injection. - PSR-17 request and stream factory discovery and injection. -- Plugin registration by priority. +- Plugin registration by priority through `PluginBuilder` / `Api::plugins()`. - PSR-6 cache configuration. - PSR-3 logger and formatter configuration. diff --git a/src/Api.php b/src/Api.php index 6152bfa..ec31c13 100644 --- a/src/Api.php +++ b/src/Api.php @@ -22,6 +22,7 @@ use ProgrammatorDev\Api\Helper\StringHelper; use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface as ClientException; +use Psr\Http\Client\ClientInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -179,6 +180,13 @@ public function cache(CacheItemPoolInterface $pool): CacheBuilder return $this->cacheBuilder; } + public function client(ClientInterface $client): ClientBuilder + { + $this->clientBuilder->client($client); + + return $this->clientBuilder; + } + public function config(?array $values = null): Config { if ($values !== null) { @@ -246,25 +254,11 @@ private function buildPlugins(): array ); } - $plugins - ->merge($this->clientBuilder->getPluginBuilder()) - ->merge($this->pluginBuilder); + $plugins->merge($this->pluginBuilder); return $plugins->all(); } - public function getClientBuilder(): ?ClientBuilder - { - return $this->clientBuilder; - } - - public function setClientBuilder(ClientBuilder $clientBuilder): self - { - $this->clientBuilder = $clientBuilder; - - return $this; - } - public function getLoggerBuilder(): ?LoggerBuilder { return $this->loggerBuilder; diff --git a/src/Builder/ClientBuilder.php b/src/Builder/ClientBuilder.php index cb4c720..1cff60f 100644 --- a/src/Builder/ClientBuilder.php +++ b/src/Builder/ClientBuilder.php @@ -3,7 +3,6 @@ namespace ProgrammatorDev\Api\Builder; use Http\Client\Common\HttpMethodsClient; -use Http\Client\Common\Plugin; use Http\Client\Common\PluginClientFactory; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; @@ -13,8 +12,6 @@ class ClientBuilder { - private PluginBuilder $pluginBuilder; - public function __construct( private ?ClientInterface $client = null, private ?RequestFactoryInterface $requestFactory = null, @@ -24,16 +21,15 @@ public function __construct( $this->client ??= Psr18ClientDiscovery::find(); $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); - $this->pluginBuilder = new PluginBuilder(); } /** - * @param list|null $plugins + * @param list<\Http\Client\Common\Plugin>|null $plugins */ public function getClient(?array $plugins = null): HttpMethodsClient { $pluginClientFactory = new PluginClientFactory(); - $client = $pluginClientFactory->createClient($this->client, $plugins ?? $this->getPlugins()); + $client = $pluginClientFactory->createClient($this->client, $plugins ?? []); return new HttpMethodsClient( $client, @@ -42,10 +38,10 @@ public function getClient(?array $plugins = null): HttpMethodsClient ); } - public function setClient(ClientInterface $client): self + public function client(ClientInterface $client): self { $this->client = $client; - + return $this; } @@ -54,7 +50,7 @@ public function getRequestFactory(): RequestFactoryInterface return $this->requestFactory; } - public function setRequestFactory(RequestFactoryInterface $requestFactory): self + public function requestFactory(RequestFactoryInterface $requestFactory): self { $this->requestFactory = $requestFactory; @@ -66,27 +62,10 @@ public function getStreamFactory(): StreamFactoryInterface return $this->streamFactory; } - public function setStreamFactory(StreamFactoryInterface $streamFactory): self + public function streamFactory(StreamFactoryInterface $streamFactory): self { $this->streamFactory = $streamFactory; return $this; } - - public function addPlugin(Plugin $plugin, int $priority): self - { - $this->pluginBuilder->add($plugin, $priority); - - return $this; - } - - public function getPlugins(): array - { - return $this->pluginBuilder->all(); - } - - public function getPluginBuilder(): PluginBuilder - { - return $this->pluginBuilder; - } } diff --git a/tests/Fixture/FakeApi.php b/tests/Fixture/FakeApi.php index 5a43baa..eceee5e 100644 --- a/tests/Fixture/FakeApi.php +++ b/tests/Fixture/FakeApi.php @@ -4,7 +4,6 @@ use Http\Mock\Client; use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\ClientBuilder; class FakeApi extends Api { @@ -12,7 +11,7 @@ public function __construct(Client $client) { parent::__construct(); - $this->setClientBuilder(new ClientBuilder($client)); + $this->client($client); $this->config(['timezone' => 'UTC']); $this diff --git a/tests/Fixture/JsonApi.php b/tests/Fixture/JsonApi.php index a06a09b..a2fa099 100644 --- a/tests/Fixture/JsonApi.php +++ b/tests/Fixture/JsonApi.php @@ -7,7 +7,6 @@ use Http\Message\Authentication\Header as HeaderAuthentication; use Http\Message\RequestMatcher\RequestMatcher; use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Context\ErrorContext; use Psr\Http\Message\RequestInterface; @@ -17,7 +16,7 @@ public function __construct(Client $client) { parent::__construct(); - $this->setClientBuilder(new ClientBuilder($client)); + $this->client($client); $this ->baseUrl('https://api.example.com') diff --git a/tests/Fixture/PlainApi.php b/tests/Fixture/PlainApi.php index f8644c5..55fcb30 100644 --- a/tests/Fixture/PlainApi.php +++ b/tests/Fixture/PlainApi.php @@ -4,7 +4,6 @@ use Http\Mock\Client; use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\ClientBuilder; class PlainApi extends Api { @@ -12,7 +11,7 @@ public function __construct(Client $client) { parent::__construct(); - $this->setClientBuilder(new ClientBuilder($client)); + $this->client($client); $this->baseUrl('https://api.example.com'); } diff --git a/tests/Unit/Builder/ClientBuilderTest.php b/tests/Unit/Builder/ClientBuilderTest.php index dd0b8f8..6a5bfc2 100644 --- a/tests/Unit/Builder/ClientBuilderTest.php +++ b/tests/Unit/Builder/ClientBuilderTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Test\Unit\Builder; -use Http\Client\Common\Plugin; use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Http\Client\ClientInterface; @@ -33,45 +32,20 @@ public function testDependencyInjection() $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); } - public function testSetters() + public function testFluentMethods() { $client = $this->createMock(ClientInterface::class); $requestFactory = $this->createMock(RequestFactoryInterface::class); $streamFactory = $this->createMock(StreamFactoryInterface::class); - $clientBuilder = new ClientBuilder(); - $clientBuilder->setClient($client); - $clientBuilder->setRequestFactory($requestFactory); - $clientBuilder->setStreamFactory($streamFactory); + $clientBuilder = (new ClientBuilder()) + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory); $this->assertInstanceOf(ClientInterface::class, $clientBuilder->getClient()); $this->assertInstanceOf(RequestFactoryInterface::class, $clientBuilder->getRequestFactory()); $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); } - public function testAddPlugin() - { - $low = $this->createMock(Plugin::class); - $high = $this->createMock(Plugin::class); - $middle = $this->createMock(Plugin::class); - $clientBuilder = new ClientBuilder(); - - $clientBuilder->addPlugin($low, 1); - $clientBuilder->addPlugin($high, 3); - $clientBuilder->addPlugin($middle, 2); - - $this->assertCount(3, $clientBuilder->getPlugins()); - $this->assertSame([$high, $middle, $low], $clientBuilder->getPlugins()); - } - - public function testAddPluginWithSamePriority() - { - $plugin = $this->createMock(Plugin::class); - $clientBuilder = new ClientBuilder(); - - $clientBuilder->addPlugin($plugin, 1); - $clientBuilder->addPlugin($plugin, 1); - - $this->assertCount(2, $clientBuilder->getPlugins()); - } } From 827fdb7ce5a09e276e5b4a85ef1ee0bf6eea449e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 5 Jun 2026 19:01:45 +0100 Subject: [PATCH 23/88] feat(v3): add request-local plugins --- docs/plugins.md | 20 +++++++++++++++++- docs/resources.md | 14 +++++++++++++ docs/v3-architecture-plan.md | 3 ++- src/Api.php | 14 +++++++++---- src/Request/RequestOptions.php | 36 ++++++++++++++++++++++++++++---- src/Resource.php | 5 +++++ tests/Integration/PluginTest.php | 27 ++++++++++++++++++++++++ 7 files changed, 109 insertions(+), 10 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index d02f11d..1258485 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -18,7 +18,15 @@ SDK users can also add plugins to a concrete API instance: $api->plugins()->add($retryPlugin, priority: 20); ``` -Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. +Resources can add plugins for one request: + +```php +return $this + ->plugins(fn (PluginBuilder $plugins) => $plugins->add($plugin, priority: 25)) + ->get('/users'); +``` + +Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. For the same priority, API-level plugins run before request-local resource plugins. ## Internal Plugin Order @@ -58,3 +66,13 @@ $this->plugins()->add($second, priority: 16); ``` The request reaches `$first` before `$second`. + +## Request-Local Plugins + +`Resource::plugins()` stores plugin configuration in request options. It does not mutate the API-level plugin builder. + +Merge order is: + +```text +internal plugins < API plugins < request-local resource plugins +``` diff --git a/docs/resources.md b/docs/resources.md index 2927bf4..44a049b 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -49,6 +49,20 @@ Returns a cloned resource with multiple header options. $this->headers(['X-Tenant' => $tenant]); ``` +## `plugins(callable $configure): static` + +Public resource modifier. + +Returns a cloned resource with request-local HTTPlug plugins. + +```php +return $this + ->plugins(fn (PluginBuilder $plugins) => $plugins->add($plugin, priority: 25)) + ->get('/users'); +``` + +Request-local plugins are applied after API-level plugins. + ## `json(array $data): static` Public resource modifier. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 2f0edce..b3d3bcc 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -356,7 +356,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. - Prefer fluent configuration over public getters. - Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. -- Keep `Resource::query()`, `Resource::queries()`, `Resource::header()`, and `Resource::headers()` as generic public primitives. +- Keep `Resource::query()`, `Resource::queries()`, `Resource::header()`, `Resource::headers()`, and `Resource::plugins()` as generic public primitives. - Resource modifiers should be immutable and return cloned resources. - `get`, `post`, `put`, `patch`, and `delete` should execute immediately. - SDK authors choose whether resource methods return entities directly or custom response envelopes. @@ -366,6 +366,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Merge order should be global defaults, then resource options, then endpoint-specific options. - Builder-backed features should follow the same default/override rule where useful: API builders define global defaults, and request-local builder overrides belong in `RequestOptions` or the pending request flow. - Request-local builder overrides should not mutate the API-level builders. +- Client configuration is global API setup only. Do not add `Resource::client()`; endpoint-specific behavior should use request-local plugins, cache, or hooks instead. - Header names should not be normalized manually. - Path parameters should be encoded with `rawurlencode`. - Query strings should use `http_build_query(..., PHP_QUERY_RFC3986)`. diff --git a/src/Api.php b/src/Api.php index ec31c13..77d9011 100644 --- a/src/Api.php +++ b/src/Api.php @@ -107,7 +107,8 @@ public function send( path: $path, query: $options->getQuery(), headers: $options->getHeaders(), - body: $options->getBody() + body: $options->getBody(), + options: $options ); $context = new Context($this->config); @@ -196,7 +197,7 @@ public function config(?array $values = null): Config return $this->config; } - private function buildPlugins(): array + private function buildPlugins(?RequestOptions $options = null): array { $plugins = new PluginBuilder(); @@ -256,6 +257,10 @@ private function buildPlugins(): array $plugins->merge($this->pluginBuilder); + if ($options?->getPlugins() !== null) { + $plugins->merge($options->getPlugins()); + } + return $plugins->all(); } @@ -348,7 +353,8 @@ private function sendRequest( string $path, array $query = [], array $headers = [], - string|StreamInterface|null $body = null + string|StreamInterface|null $body = null, + ?RequestOptions $options = null ): ResponseInterface { if (!empty($this->queryDefaults)) { @@ -361,7 +367,7 @@ private function sendRequest( $url = $this->buildUrl($path, $query); $request = $this->createRequest($method, $url, $headers, $body); - $plugins = $this->buildPlugins(); + $plugins = $this->buildPlugins($options); // pre request listener $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); diff --git a/src/Request/RequestOptions.php b/src/Request/RequestOptions.php index ee1e1fa..b9ea803 100644 --- a/src/Request/RequestOptions.php +++ b/src/Request/RequestOptions.php @@ -2,6 +2,7 @@ namespace ProgrammatorDev\Api\Request; +use ProgrammatorDev\Api\Builder\PluginBuilder; use Psr\Http\Message\StreamInterface; class RequestOptions @@ -9,7 +10,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 ?PluginBuilder $plugins = null ) {} public function getQuery(): array @@ -27,6 +29,11 @@ public function getBody(): string|StreamInterface|null return $this->body; } + public function getPlugins(): ?PluginBuilder + { + return $this->plugins; + } + public function withQuery(string $name, mixed $value): self { return $this->withQueries([$name => $value]); @@ -37,7 +44,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, + plugins: $this->plugins ); } @@ -51,7 +59,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, + plugins: $this->plugins ); } @@ -60,7 +69,26 @@ public function withBody(string|StreamInterface|null $body): self return new self( query: $this->query, headers: $this->headers, - body: $body + body: $body, + plugins: $this->plugins + ); + } + + public function withPlugins(callable $configure): self + { + $plugins = new PluginBuilder(); + + if ($this->plugins !== null) { + $plugins->merge($this->plugins); + } + + $configure($plugins); + + return new self( + query: $this->query, + headers: $this->headers, + body: $this->body, + plugins: $plugins ); } diff --git a/src/Resource.php b/src/Resource.php index 4fef454..f486c28 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -36,6 +36,11 @@ public function headers(array $headers): static return $this->withOptions($this->options->withHeaders($headers)); } + public function plugins(callable $configure): static + { + return $this->withOptions($this->options->withPlugins($configure)); + } + public function json(array $data): static { return $this diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php index 0a9186e..ed82682 100644 --- a/tests/Integration/PluginTest.php +++ b/tests/Integration/PluginTest.php @@ -79,6 +79,33 @@ public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void $this->assertSame(['once'], $client->getRequests()[1]->getHeader('X-Plugin-Order')); } + public function testResourcePluginsAreAppliedToOneRequest(): void + { + $client = $this->client(responses: 2); + $api = new JsonApi($client); + + $api->raw() + ->plugins(fn ($plugins) => $plugins->add($this->headerPlugin('resource'), priority: 16)) + ->fetch(); + + $api->raw()->fetch(); + + $this->assertSame(['resource'], $client->getRequests()[0]->getHeader('X-Plugin-Order')); + $this->assertSame([], $client->getRequests()[1]->getHeader('X-Plugin-Order')); + } + + public function testResourcePluginsAreMergedAfterGlobalPlugins(): void + { + $client = $this->client(responses: 1); + $api = (new JsonApi($client))->usePlugin($this->headerPlugin('global'), priority: 16); + + $api->raw() + ->plugins(fn ($plugins) => $plugins->add($this->headerPlugin('resource'), priority: 16)) + ->fetch(); + + $this->assertSame(['global', 'resource'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + private function headerPlugin(string $value): Plugin { return new class($value) implements Plugin { From 8211b207702fb188f2d9984a9e9a949955b7adfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 10:00:55 +0100 Subject: [PATCH 24/88] revert(v3): defer request-local plugins --- docs/plugins.md | 20 +----------------- docs/resources.md | 14 ------------- docs/v3-architecture-plan.md | 7 +++---- src/Api.php | 8 ++----- src/Request/RequestOptions.php | 36 ++++---------------------------- src/Resource.php | 5 ----- tests/Integration/PluginTest.php | 27 ------------------------ 7 files changed, 10 insertions(+), 107 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 1258485..d02f11d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -18,15 +18,7 @@ SDK users can also add plugins to a concrete API instance: $api->plugins()->add($retryPlugin, priority: 20); ``` -Resources can add plugins for one request: - -```php -return $this - ->plugins(fn (PluginBuilder $plugins) => $plugins->add($plugin, priority: 25)) - ->get('/users'); -``` - -Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. For the same priority, API-level plugins run before request-local resource plugins. +Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. ## Internal Plugin Order @@ -66,13 +58,3 @@ $this->plugins()->add($second, priority: 16); ``` The request reaches `$first` before `$second`. - -## Request-Local Plugins - -`Resource::plugins()` stores plugin configuration in request options. It does not mutate the API-level plugin builder. - -Merge order is: - -```text -internal plugins < API plugins < request-local resource plugins -``` diff --git a/docs/resources.md b/docs/resources.md index 44a049b..2927bf4 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -49,20 +49,6 @@ Returns a cloned resource with multiple header options. $this->headers(['X-Tenant' => $tenant]); ``` -## `plugins(callable $configure): static` - -Public resource modifier. - -Returns a cloned resource with request-local HTTPlug plugins. - -```php -return $this - ->plugins(fn (PluginBuilder $plugins) => $plugins->add($plugin, priority: 25)) - ->get('/users'); -``` - -Request-local plugins are applied after API-level plugins. - ## `json(array $data): static` Public resource modifier. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index b3d3bcc..74b9331 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -356,7 +356,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. - Prefer fluent configuration over public getters. - Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. -- Keep `Resource::query()`, `Resource::queries()`, `Resource::header()`, `Resource::headers()`, and `Resource::plugins()` as generic public primitives. +- Keep `Resource::query()`, `Resource::queries()`, `Resource::header()`, and `Resource::headers()` as generic public primitives. - Resource modifiers should be immutable and return cloned resources. - `get`, `post`, `put`, `patch`, and `delete` should execute immediately. - SDK authors choose whether resource methods return entities directly or custom response envelopes. @@ -364,9 +364,8 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::as()`. - No reset methods for resource options in the first phase. - Merge order should be global defaults, then resource options, then endpoint-specific options. -- Builder-backed features should follow the same default/override rule where useful: API builders define global defaults, and request-local builder overrides belong in `RequestOptions` or the pending request flow. -- Request-local builder overrides should not mutate the API-level builders. -- Client configuration is global API setup only. Do not add `Resource::client()`; endpoint-specific behavior should use request-local plugins, cache, or hooks instead. +- Client configuration is global API setup only. Do not add `Resource::client()`. +- Defer request-local plugins, cache, hooks, and similar pipeline options until the request-local architecture is clearer. Avoid ad hoc builder cloning or one-off request option shapes. If request-local cache is added later, prefer a smaller cache options object that stores only override values such as default TTL, methods, and cache directives, then merge it with the API-level cache builder during send. - Header names should not be normalized manually. - Path parameters should be encoded with `rawurlencode`. - Query strings should use `http_build_query(..., PHP_QUERY_RFC3986)`. diff --git a/src/Api.php b/src/Api.php index 77d9011..b6f6fcf 100644 --- a/src/Api.php +++ b/src/Api.php @@ -197,7 +197,7 @@ public function config(?array $values = null): Config return $this->config; } - private function buildPlugins(?RequestOptions $options = null): array + private function buildPlugins(): array { $plugins = new PluginBuilder(); @@ -257,10 +257,6 @@ private function buildPlugins(?RequestOptions $options = null): array $plugins->merge($this->pluginBuilder); - if ($options?->getPlugins() !== null) { - $plugins->merge($options->getPlugins()); - } - return $plugins->all(); } @@ -367,7 +363,7 @@ private function sendRequest( $url = $this->buildUrl($path, $query); $request = $this->createRequest($method, $url, $headers, $body); - $plugins = $this->buildPlugins($options); + $plugins = $this->buildPlugins(); // pre request listener $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); diff --git a/src/Request/RequestOptions.php b/src/Request/RequestOptions.php index b9ea803..ee1e1fa 100644 --- a/src/Request/RequestOptions.php +++ b/src/Request/RequestOptions.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Request; -use ProgrammatorDev\Api\Builder\PluginBuilder; use Psr\Http\Message\StreamInterface; class RequestOptions @@ -10,8 +9,7 @@ class RequestOptions public function __construct( private readonly array $query = [], private readonly array $headers = [], - private readonly string|StreamInterface|null $body = null, - private readonly ?PluginBuilder $plugins = null + private readonly string|StreamInterface|null $body = null ) {} public function getQuery(): array @@ -29,11 +27,6 @@ public function getBody(): string|StreamInterface|null return $this->body; } - public function getPlugins(): ?PluginBuilder - { - return $this->plugins; - } - public function withQuery(string $name, mixed $value): self { return $this->withQueries([$name => $value]); @@ -44,8 +37,7 @@ public function withQueries(array $query): self return new self( query: array_merge($this->query, $this->filterNullValues($query)), headers: $this->headers, - body: $this->body, - plugins: $this->plugins + body: $this->body ); } @@ -59,8 +51,7 @@ public function withHeaders(array $headers): self return new self( query: $this->query, headers: array_merge($this->headers, $headers), - body: $this->body, - plugins: $this->plugins + body: $this->body ); } @@ -69,26 +60,7 @@ public function withBody(string|StreamInterface|null $body): self return new self( query: $this->query, headers: $this->headers, - body: $body, - plugins: $this->plugins - ); - } - - public function withPlugins(callable $configure): self - { - $plugins = new PluginBuilder(); - - if ($this->plugins !== null) { - $plugins->merge($this->plugins); - } - - $configure($plugins); - - return new self( - query: $this->query, - headers: $this->headers, - body: $this->body, - plugins: $plugins + body: $body ); } diff --git a/src/Resource.php b/src/Resource.php index f486c28..4fef454 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -36,11 +36,6 @@ public function headers(array $headers): static return $this->withOptions($this->options->withHeaders($headers)); } - public function plugins(callable $configure): static - { - return $this->withOptions($this->options->withPlugins($configure)); - } - public function json(array $data): static { return $this diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php index ed82682..0a9186e 100644 --- a/tests/Integration/PluginTest.php +++ b/tests/Integration/PluginTest.php @@ -79,33 +79,6 @@ public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void $this->assertSame(['once'], $client->getRequests()[1]->getHeader('X-Plugin-Order')); } - public function testResourcePluginsAreAppliedToOneRequest(): void - { - $client = $this->client(responses: 2); - $api = new JsonApi($client); - - $api->raw() - ->plugins(fn ($plugins) => $plugins->add($this->headerPlugin('resource'), priority: 16)) - ->fetch(); - - $api->raw()->fetch(); - - $this->assertSame(['resource'], $client->getRequests()[0]->getHeader('X-Plugin-Order')); - $this->assertSame([], $client->getRequests()[1]->getHeader('X-Plugin-Order')); - } - - public function testResourcePluginsAreMergedAfterGlobalPlugins(): void - { - $client = $this->client(responses: 1); - $api = (new JsonApi($client))->usePlugin($this->headerPlugin('global'), priority: 16); - - $api->raw() - ->plugins(fn ($plugins) => $plugins->add($this->headerPlugin('resource'), priority: 16)) - ->fetch(); - - $this->assertSame(['global', 'resource'], $client->getLastRequest()->getHeader('X-Plugin-Order')); - } - private function headerPlugin(string $value): Plugin { return new class($value) implements Plugin { From daff8084b6dfdd5f99ecfff40e1473257a7b11f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 10:06:35 +0100 Subject: [PATCH 25/88] refactor(v3): make logger configuration fluent --- docs/api.md | 10 ++++++++++ docs/v3-architecture-plan.md | 3 +-- src/Api.php | 20 ++++++++------------ src/Builder/LoggerBuilder.php | 6 +++--- tests/Unit/Builder/LoggerBuilderTest.php | 10 +++++----- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/docs/api.md b/docs/api.md index c2c11aa..65600a1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -127,6 +127,16 @@ $this ->streamFactory($streamFactory); ``` +## `logger(LoggerInterface $logger): LoggerBuilder` + +Public access to PSR-3 logger configuration. + +```php +$api + ->logger($logger) + ->formatter($formatter); +``` + ## `responses(): ResponseBuilder` Protected access to response decoding configuration. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 74b9331..863bd39 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -201,8 +201,7 @@ Current public methods: - `request` - `client` -- `getLoggerBuilder` -- `setLoggerBuilder` +- `logger` - `plugins` - `cache` - `config` diff --git a/src/Api.php b/src/Api.php index b6f6fcf..aafa91b 100644 --- a/src/Api.php +++ b/src/Api.php @@ -27,6 +27,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcher; class Api @@ -188,6 +189,13 @@ public function client(ClientInterface $client): ClientBuilder return $this->clientBuilder; } + public function logger(LoggerInterface $logger): LoggerBuilder + { + $this->loggerBuilder = new LoggerBuilder($logger); + + return $this->loggerBuilder; + } + public function config(?array $values = null): Config { if ($values !== null) { @@ -260,18 +268,6 @@ private function buildPlugins(): array return $plugins->all(); } - public function getLoggerBuilder(): ?LoggerBuilder - { - return $this->loggerBuilder; - } - - public function setLoggerBuilder(?LoggerBuilder $loggerBuilder): self - { - $this->loggerBuilder = $loggerBuilder; - - return $this; - } - public function addPreRequestListener(callable $listener, int $priority = 0): self { $this->eventDispatcher->addListener(PreRequestEvent::class, $listener, $priority); diff --git a/src/Builder/LoggerBuilder.php b/src/Builder/LoggerBuilder.php index 863ed5f..02602a0 100644 --- a/src/Builder/LoggerBuilder.php +++ b/src/Builder/LoggerBuilder.php @@ -22,7 +22,7 @@ public function getLogger(): LoggerInterface return $this->logger; } - public function setLogger(LoggerInterface $logger): self + public function logger(LoggerInterface $logger): self { $this->logger = $logger; @@ -34,10 +34,10 @@ public function getFormatter(): Formatter return $this->formatter; } - public function setFormatter(Formatter $formatter): self + public function formatter(Formatter $formatter): self { $this->formatter = $formatter; return $this; } -} \ No newline at end of file +} diff --git a/tests/Unit/Builder/LoggerBuilderTest.php b/tests/Unit/Builder/LoggerBuilderTest.php index e763158..73c89b5 100644 --- a/tests/Unit/Builder/LoggerBuilderTest.php +++ b/tests/Unit/Builder/LoggerBuilderTest.php @@ -30,16 +30,16 @@ public function testDependencyInjection() $this->assertInstanceOf(Formatter::class, $loggerBuilder->getFormatter()); } - public function testSetters() + public function testFluentMethods() { $logger = $this->createMock(LoggerInterface::class); $formatter = $this->createMock(Formatter::class); - $loggerBuilder = new LoggerBuilder($logger); - $loggerBuilder->setLogger($logger); - $loggerBuilder->setFormatter($formatter); + $loggerBuilder = (new LoggerBuilder($logger)) + ->logger($logger) + ->formatter($formatter); $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); $this->assertInstanceOf(Formatter::class, $loggerBuilder->getFormatter()); } -} \ No newline at end of file +} From 79d41c6652c42f8e422d36c11c7e1a637473e866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 10:11:52 +0100 Subject: [PATCH 26/88] refactor(v3): clarify cache logging --- src/Builder/Listener/CacheLoggerListener.php | 24 +++-- .../Listener/CacheLoggerListenerTest.php | 99 +++++++++++++++++++ 2 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/Builder/Listener/CacheLoggerListenerTest.php diff --git a/src/Builder/Listener/CacheLoggerListener.php b/src/Builder/Listener/CacheLoggerListener.php index 74a3180..a936de3 100644 --- a/src/Builder/Listener/CacheLoggerListener.php +++ b/src/Builder/Listener/CacheLoggerListener.php @@ -22,33 +22,37 @@ public function onCacheResponse( $logger = $this->loggerBuilder->getLogger(); $formatter = $this->loggerBuilder->getFormatter(); - // if response is a cache hit if ($fromCache) { /** @var $cacheItem CacheItemInterface */ $logger->info( - sprintf("Cache hit:\n%s", $formatter->formatRequest($request)), + sprintf("HTTP cache hit:\n%s", $formatter->formatRequest($request)), [ - 'expires' => $cacheItem->get()['expiresAt'], - 'key' => $cacheItem->getKey() + 'cache_expires_at' => $this->getExpiresAt($cacheItem), + 'cache_key' => $cacheItem->getKey() ] ); } - // if response is a cache miss (and was cached) else if ($cacheItem instanceof CacheItemInterface) { - // handle future deprecation $formattedResponse = method_exists($formatter, 'formatResponseForRequest') ? $formatter->formatResponseForRequest($response, $request) : $formatter->formatResponse($response); $logger->info( - sprintf("Cached response:\n%s", $formattedResponse), + sprintf("HTTP response cached:\n%s", $formattedResponse), [ - 'expires' => $cacheItem->get()['expiresAt'], - 'key' => $cacheItem->getKey() + 'cache_expires_at' => $this->getExpiresAt($cacheItem), + 'cache_key' => $cacheItem->getKey() ] ); } return $response; } -} \ No newline at end of file + + private function getExpiresAt(CacheItemInterface $cacheItem): mixed + { + $data = $cacheItem->get(); + + return is_array($data) ? $data['expiresAt'] ?? null : null; + } +} diff --git a/tests/Unit/Builder/Listener/CacheLoggerListenerTest.php b/tests/Unit/Builder/Listener/CacheLoggerListenerTest.php new file mode 100644 index 0000000..f881812 --- /dev/null +++ b/tests/Unit/Builder/Listener/CacheLoggerListenerTest.php @@ -0,0 +1,99 @@ +createMock(LoggerInterface::class); + $cacheItem = $this->cacheItem(); + + $logger + ->expects($this->once()) + ->method('info') + ->with( + $this->stringStartsWith('HTTP cache hit:'), + [ + 'cache_expires_at' => 1234567890, + 'cache_key' => 'cache-key', + ] + ); + + $listener = new CacheLoggerListener(new LoggerBuilder($logger)); + + $listener->onCacheResponse( + new Request('GET', 'https://api.example.com/users'), + new Response(body: '{}'), + true, + $cacheItem + ); + } + + public function testCachedResponseIsLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + $cacheItem = $this->cacheItem(); + + $logger + ->expects($this->once()) + ->method('info') + ->with( + $this->stringStartsWith('HTTP response cached:'), + [ + 'cache_expires_at' => 1234567890, + 'cache_key' => 'cache-key', + ] + ); + + $listener = new CacheLoggerListener(new LoggerBuilder($logger)); + + $listener->onCacheResponse( + new Request('GET', 'https://api.example.com/users'), + new Response(body: '{}'), + false, + $cacheItem + ); + } + + public function testCacheMissWithoutStoredResponseIsNotLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + + $logger + ->expects($this->never()) + ->method('info'); + + $listener = new CacheLoggerListener(new LoggerBuilder($logger)); + + $listener->onCacheResponse( + new Request('GET', 'https://api.example.com/users'), + new Response(body: '{}'), + false, + null + ); + } + + private function cacheItem(): CacheItemInterface + { + $cacheItem = $this->createMock(CacheItemInterface::class); + + $cacheItem + ->method('get') + ->willReturn(['expiresAt' => 1234567890]); + + $cacheItem + ->method('getKey') + ->willReturn('cache-key'); + + return $cacheItem; + } +} From 4f4ff66c17b1a37a1de687a90e43d336d2462c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 11:03:11 +0100 Subject: [PATCH 27/88] refactor(v3): clarify plugin infrastructure --- docs/api.md | 4 +++ docs/cache.md | 2 ++ docs/http-client.md | 73 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 2 ++ docs/logging.md | 55 +++++++++++++++++++++++++++++++ docs/plugins.md | 2 ++ src/Api.php | 79 +++++++++++++++++++++++++++++---------------- 7 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 docs/http-client.md create mode 100644 docs/logging.md diff --git a/docs/api.md b/docs/api.md index 65600a1..6f1c9d0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -127,6 +127,8 @@ $this ->streamFactory($streamFactory); ``` +See [HTTP Client](http-client.md) for client and factory configuration. + ## `logger(LoggerInterface $logger): LoggerBuilder` Public access to PSR-3 logger configuration. @@ -137,6 +139,8 @@ $api ->formatter($formatter); ``` +See [Logging](logging.md) for logger formatting and cache logging. + ## `responses(): ResponseBuilder` Protected access to response decoding configuration. diff --git a/docs/cache.md b/docs/cache.md index 908f80b..1c8f3fc 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -46,3 +46,5 @@ Sets the response cache directives respected by the cache plugin. The cache plugin runs at priority `20`, after authentication and before the logger plugin. When logging is configured, cache hit/miss/write events are logged through the cache plugin listener. + +See [Logging](logging.md) for cache log output. diff --git a/docs/http-client.md b/docs/http-client.md new file mode 100644 index 0000000..94c9922 --- /dev/null +++ b/docs/http-client.md @@ -0,0 +1,73 @@ +# HTTP Client + +The SDK uses a PSR-18 HTTP client to send requests and PSR-17 factories to create requests and streams. + +If compatible implementations are installed, the package can discover them automatically through PHP-HTTP discovery. SDK users can also provide concrete implementations explicitly. + +```php +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Discovery\Psr18ClientDiscovery; + +$api + ->client(Psr18ClientDiscovery::find()) + ->requestFactory(Psr17FactoryDiscovery::findRequestFactory()) + ->streamFactory(Psr17FactoryDiscovery::findStreamFactory()); +``` + +## SDK Author Defaults + +SDK authors can configure the client and factories inside the API constructor when the SDK should control its defaults. + +```php +use Programmatordev\ApiSdk\Api; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; + +final class ExampleApi extends Api +{ + public function __construct( + ClientInterface $client, + RequestFactoryInterface $requestFactory, + StreamFactoryInterface $streamFactory, + string $apiKey, + ) { + $this + ->baseUrl('https://api.example.com') + ->queryDefaults(['api_key' => $apiKey]) + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory) + ->responses() + ->json(); + } +} +``` + +## SDK User Overrides + +SDK users can replace the client on a concrete API instance. + +```php +$api->client($client); +``` + +Factories can be replaced through the returned builder. + +```php +$api + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory); +``` + +## Plugins + +HTTPlug plugins are not configured on the client builder. They are configured through `plugins()` so global middleware has one predictable place to live. + +```php +$api->plugins()->add($plugin, priority: 25); +``` + +See [Plugins](plugins.md) for plugin order and priority guidance. + diff --git a/docs/index.md b/docs/index.md index 29decde..16e716d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,9 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. - [Authentication](authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [HTTP Client](http-client.md): configure PSR-18 clients and PSR-17 factories. - [Cache](cache.md): configure PSR-6 HTTP response caching. +- [Logging](logging.md): configure PSR-3 logging and HTTP/cache log output. - [Plugins](plugins.md): configure HTTPlug middleware and priority ordering. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. - [API Reference](api-reference.md): current v3 authoring methods and contracts. diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..c931e4d --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,55 @@ +# Logging + +Logging uses the [PHP-HTTP logger plugin](https://docs.php-http.org/en/latest/plugins/logger.html) with a PSR-3 logger. + +SDK users can configure logging on an API instance: + +```php +$api->logger($logger); +``` + +SDK authors can also configure logging from the `Api` class: + +```php +$this->logger($logger); +``` + +## Formatter + +The logger plugin can receive a custom formatter. + +```php +$api + ->logger($logger) + ->formatter($formatter); +``` + +The formatter is passed directly to the HTTPlug logger plugin. + +## Cache Logging + +When cache and logging are both configured, cache activity is also logged through the cache plugin listener. + +```php +$api + ->cache($pool) + ->defaultTtl(3600); + +$api->logger($logger); +``` + +The cache listener logs: + +- `HTTP cache hit:` when a cached response is reused. +- `HTTP response cached:` when a response is stored. + +The log context includes the cache key and, when available, the cache expiration date. + +## Internal Order + +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](plugins.md) for the full internal plugin order. + diff --git a/docs/plugins.md b/docs/plugins.md index d02f11d..3b2f9b2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -4,6 +4,8 @@ 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](http-client.md). Plugins are configured separately so middleware order remains explicit. + SDK authors can configure plugins from the `Api` class: ```php diff --git a/src/Api.php b/src/Api.php index aafa91b..be270e8 100644 --- a/src/Api.php +++ b/src/Api.php @@ -2,6 +2,7 @@ namespace ProgrammatorDev\Api; +use Http\Client\Common\Plugin; use Http\Client\Common\Plugin\AuthenticationPlugin; use Http\Client\Common\Plugin\CachePlugin; use Http\Client\Common\Plugin\ContentLengthPlugin; @@ -32,6 +33,12 @@ class Api { + private const CONTENT_TYPE_PLUGIN_PRIORITY = 50; + private const CONTENT_LENGTH_PLUGIN_PRIORITY = 40; + private const AUTHENTICATION_PLUGIN_PRIORITY = 30; + private const CACHE_PLUGIN_PRIORITY = 20; + private const LOGGER_PLUGIN_PRIORITY = 10; + private ?string $baseUrl = null; private array $queryDefaults = []; @@ -212,54 +219,36 @@ private function buildPlugins(): array // https://docs.php-http.org/en/latest/plugins/content-type.html $plugins->add( plugin: new ContentTypePlugin(), - priority: 50 + priority: self::CONTENT_TYPE_PLUGIN_PRIORITY ); // https://docs.php-http.org/en/latest/plugins/content-length.html $plugins->add( plugin: new ContentLengthPlugin(), - priority: 40 + priority: self::CONTENT_LENGTH_PLUGIN_PRIORITY ); // https://docs.php-http.org/en/latest/message/authentication.html if ($authentication = $this->authBuilder->authentication()) { $plugins->add( plugin: new AuthenticationPlugin($authentication), - priority: 30 + priority: self::AUTHENTICATION_PLUGIN_PRIORITY ); } // https://docs.php-http.org/en/latest/plugins/cache.html - if ($this->cacheBuilder) { - $cacheOptions = [ - 'default_ttl' => $this->cacheBuilder->getDefaultTtl(), - 'methods' => $this->cacheBuilder->getMethods(), - 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), - 'cache_listeners' => [] - ]; - - if ($this->loggerBuilder) { - $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); - } - + if ($cachePlugin = $this->buildCachePlugin()) { $plugins->add( - plugin: new CachePlugin( - $this->cacheBuilder->getPool(), - $this->clientBuilder->getStreamFactory(), - $cacheOptions - ), - priority: 20 + plugin: $cachePlugin, + priority: self::CACHE_PLUGIN_PRIORITY ); } // https://docs.php-http.org/en/latest/plugins/logger.html - if ($this->loggerBuilder) { + if ($loggerPlugin = $this->buildLoggerPlugin()) { $plugins->add( - plugin: new LoggerPlugin( - $this->loggerBuilder->getLogger(), - $this->loggerBuilder->getFormatter() - ), - priority: 10 + plugin: $loggerPlugin, + priority: self::LOGGER_PLUGIN_PRIORITY ); } @@ -268,6 +257,42 @@ private function buildPlugins(): array return $plugins->all(); } + private function buildCachePlugin(): ?Plugin + { + if ($this->cacheBuilder === null) { + return null; + } + + $cacheOptions = [ + 'default_ttl' => $this->cacheBuilder->getDefaultTtl(), + 'methods' => $this->cacheBuilder->getMethods(), + 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), + 'cache_listeners' => [] + ]; + + if ($this->loggerBuilder) { + $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); + } + + return new CachePlugin( + $this->cacheBuilder->getPool(), + $this->clientBuilder->getStreamFactory(), + $cacheOptions + ); + } + + private function buildLoggerPlugin(): ?Plugin + { + if ($this->loggerBuilder === null) { + return null; + } + + return new LoggerPlugin( + $this->loggerBuilder->getLogger(), + $this->loggerBuilder->getFormatter() + ); + } + public function addPreRequestListener(callable $listener, int $priority = 0): self { $this->eventDispatcher->addListener(PreRequestEvent::class, $listener, $priority); From f9ac02e97454f40780dc00728d4fa20f90d70d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 11:12:00 +0100 Subject: [PATCH 28/88] refactor(v3): remove legacy response contents events --- docs/v3-architecture-plan.md | 6 +---- src/Api.php | 35 ----------------------------- src/Event/ResponseContentsEvent.php | 22 ------------------ 3 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 src/Event/ResponseContentsEvent.php diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 863bd39..498d96f 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -199,7 +199,6 @@ Start with a minimal context API. Add richer access only when implementation nee Current public methods: -- `request` - `client` - `logger` - `plugins` @@ -207,7 +206,6 @@ Current public methods: - `config` - `addPreRequestListener` - `addPostRequestListener` -- `addResponseContentsListener` Observations: @@ -245,13 +243,11 @@ Current event classes: - `PreRequestEvent` - `PostRequestEvent` -- `ResponseContentsEvent` Current capabilities: - Mutate request before sending. - Mutate response after sending. -- Transform response contents. These capabilities should remain, but v3 should consider clearer first-class APIs for common cases: @@ -333,7 +329,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon | Plugins | Use HTTPlug `PluginClientBuilder`-style priority handling; preserve multiple plugins at the same priority | | Cache | Keep PSR-6 support, likely through fluent `cache(...)` | | Logger | Keep PSR-3 support, likely through fluent `logger(...)` | -| `ResponseContentsEvent` for JSON | First-class `responses()->json()` | +| `ResponseContentsEvent` for JSON | Removed; first-class `responses()->json()` | | Post-response listener for errors | First-class status and callback-based error mapping, while preserving hooks | | Raw response body return | `Response::data()` or `Response::raw()` depending on configuration | | Manual entity construction in resources | `Response::entity(...)`, `Response::collection(...)`, and `Response::as(...)` helpers | diff --git a/src/Api.php b/src/Api.php index be270e8..225545d 100644 --- a/src/Api.php +++ b/src/Api.php @@ -19,7 +19,6 @@ use ProgrammatorDev\Api\Context\ErrorContext; use ProgrammatorDev\Api\Event\PostRequestEvent; use ProgrammatorDev\Api\Event\PreRequestEvent; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; use ProgrammatorDev\Api\Helper\StringHelper; use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface as ClientException; @@ -74,28 +73,6 @@ public function __construct() $this->eventDispatcher = new EventDispatcher(); } - /** - * @throws ClientException - */ - public function request( - string $method, - string $path, - array $query = [], - array $headers = [], - string|StreamInterface|null $body = null - ): mixed - { - $response = $this->sendRequest($method, $path, $query, $headers, $body); - - // always rewind the body contents in case it was used in the PostRequestEvent - // otherwise it would return an empty string - $response->getBody()->rewind(); - $contents = $response->getBody()->getContents(); - - // response contents listener - return $this->eventDispatcher->dispatch(new ResponseContentsEvent($contents))->getContents(); - } - /** * @internal * @throws ClientException @@ -216,19 +193,16 @@ private function buildPlugins(): array { $plugins = new PluginBuilder(); - // https://docs.php-http.org/en/latest/plugins/content-type.html $plugins->add( plugin: new ContentTypePlugin(), priority: self::CONTENT_TYPE_PLUGIN_PRIORITY ); - // https://docs.php-http.org/en/latest/plugins/content-length.html $plugins->add( plugin: new ContentLengthPlugin(), priority: self::CONTENT_LENGTH_PLUGIN_PRIORITY ); - // https://docs.php-http.org/en/latest/message/authentication.html if ($authentication = $this->authBuilder->authentication()) { $plugins->add( plugin: new AuthenticationPlugin($authentication), @@ -236,7 +210,6 @@ private function buildPlugins(): array ); } - // https://docs.php-http.org/en/latest/plugins/cache.html if ($cachePlugin = $this->buildCachePlugin()) { $plugins->add( plugin: $cachePlugin, @@ -244,7 +217,6 @@ private function buildPlugins(): array ); } - // https://docs.php-http.org/en/latest/plugins/logger.html if ($loggerPlugin = $this->buildLoggerPlugin()) { $plugins->add( plugin: $loggerPlugin, @@ -307,13 +279,6 @@ public function addPostRequestListener(callable $listener, int $priority = 0): s return $this; } - public function addResponseContentsListener(callable $listener, int $priority = 0): self - { - $this->eventDispatcher->addListener(ResponseContentsEvent::class, $listener, $priority); - - return $this; - } - private function buildPath(string $path, array $parameters): string { foreach ($parameters as $parameter => $value) { diff --git a/src/Event/ResponseContentsEvent.php b/src/Event/ResponseContentsEvent.php deleted file mode 100644 index da42560..0000000 --- a/src/Event/ResponseContentsEvent.php +++ /dev/null @@ -1,22 +0,0 @@ -contents; - } - - public function setContents($contents): void - { - $this->contents = $contents; - } -} \ No newline at end of file From 1db804415887bd6ae98c58a32b7c6e8ed2d3e399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 11:25:44 +0100 Subject: [PATCH 29/88] feat(v3): add request lifecycle hooks --- docs/api-reference.md | 1 + docs/api.md | 13 ++++ docs/hooks.md | 102 ++++++++++++++++++++++++++++ docs/index.md | 1 + docs/v3-architecture-plan.md | 3 +- src/Api.php | 32 +++++++-- src/Builder/HookBuilder.php | 97 +++++++++++++++++++++++++++ src/Context/RequestContext.php | 24 +++++++ src/Context/ResponseContext.php | 31 +++++++++ tests/Fixture/JsonApi.php | 22 +++++++ tests/Integration/HookTest.php | 113 ++++++++++++++++++++++++++++++++ 11 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 docs/hooks.md create mode 100644 src/Builder/HookBuilder.php create mode 100644 src/Context/RequestContext.php create mode 100644 src/Context/ResponseContext.php create mode 100644 tests/Integration/HookTest.php diff --git a/docs/api-reference.md b/docs/api-reference.md index df50d38..112b6e0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,5 +6,6 @@ This reference is split by where methods are available. - [Authentication](authentication.md): `AuthBuilder` helpers and custom authentication. - [Cache](cache.md): `CacheBuilder` helpers and PSR-6 cache configuration. - [Plugins](plugins.md): `PluginBuilder` helpers and internal plugin order. +- [Hooks](hooks.md): `HookBuilder`, request hooks, response hooks, and hook contexts. - [Resources](resources.md): resource modifiers and protected request helpers. - [Responses](responses.md): `Response`, `Entity`, `ResponseEnvelope`, and `Context`. diff --git a/docs/api.md b/docs/api.md index 6f1c9d0..7dc31c6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,19 @@ Authentication is applied automatically to outgoing requests. See [Authentication](authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. +## `hooks(): HookBuilder` + +Public access to request and response hooks. + +```php +$this->hooks()->beforeRequest($hook); +$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](hooks.md) for hook context objects, return values, and priority behavior. + ## `plugins(): PluginBuilder` Public access to HTTPlug plugin configuration. diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..af8d3c5 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,102 @@ +# Hooks + +Hooks let SDK authors and SDK users run callbacks around the HTTP request without exposing low-level request execution. + +SDK authors can configure hooks from an `Api` subclass: + +```php +use ProgrammatorDev\Api\Api; +use ProgrammatorDev\Api\Context\RequestContext; +use ProgrammatorDev\Api\Context\ResponseContext; + +final class ExampleApi extends Api +{ + public function __construct(string $apiKey) + { + $this + ->baseUrl('https://api.example.com') + ->responses() + ->json(); + + $this->hooks()->beforeRequest( + fn (RequestContext $context) => $context + ->request() + ->withHeader('X-Api-Key', $apiKey) + ); + + $this->hooks()->afterResponse( + fn (ResponseContext $context) => $context->response() + ); + } +} +``` + +## Before Request + +`beforeRequest()` runs after the PSR-7 request is created and before it is sent. + +```php +$this->hooks()->beforeRequest(function (RequestContext $context) { + return $context->request()->withHeader('X-Tenant', $context->apiContext()->config()->get('tenant')); +}); +``` + +Return a `RequestInterface` to replace the request. Return `null` to leave it unchanged. Any other return value throws. + +## After Response + +`afterResponse()` runs after the HTTP response is received and before response decoding, response wrapping, and error handling. + +```php +$this->hooks()->afterResponse(function (ResponseContext $context) { + return $context->response(); +}); +``` + +Return a `ResponseInterface` to replace the response. Return `null` to leave it unchanged. Any other return value throws. + +## Priority + +Higher priority hooks run earlier. Hooks with the same priority run in insertion order. + +```php +$this->hooks()->beforeRequest($first, priority: 20); +$this->hooks()->beforeRequest($second, priority: 20); +$this->hooks()->beforeRequest($later, priority: 10); +``` + +The request reaches `$first`, then `$second`, then `$later`. + +## Context + +`RequestContext` exposes: + +- `request()` +- `apiContext()` + +`ResponseContext` exposes: + +- `request()` +- `response()` +- `apiContext()` + +The shared `Context` gives hooks access to SDK config without injecting the full API instance. + +## Order + +The current v3 request flow is: + +```text +create request +beforeRequest hooks +legacy pre-request events +send request +afterResponse hooks +legacy post-request events +decode response +create Response +errors +return Response +``` + +The legacy event layer is still present temporarily while v3 hooks settle. diff --git a/docs/index.md b/docs/index.md index 16e716d..c801a18 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,5 +28,6 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Cache](cache.md): configure PSR-6 HTTP response caching. - [Logging](logging.md): configure PSR-3 logging and HTTP/cache log output. - [Plugins](plugins.md): configure HTTPlug middleware and priority ordering. +- [Hooks](hooks.md): run SDK-author callbacks around requests and responses. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. - [API Reference](api-reference.md): current v3 authoring methods and contracts. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 498d96f..70c56ac 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -266,7 +266,7 @@ $this->hooks()->beforeRequest( ); $this->hooks()->afterResponse( - fn (ResponseContext $context) => $context->rawResponse() + fn (ResponseContext $context) => $context->response() ); ``` @@ -348,6 +348,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - `Response::collection()` should return a plain array by default. - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. - Symfony EventDispatcher should be replaced with a smaller request/response pipeline. +- v3-native hooks are represented by `HookBuilder`, `RequestContext`, and `ResponseContext`. - Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. - Prefer fluent configuration over public getters. - Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. diff --git a/src/Api.php b/src/Api.php index 225545d..0bfdcb6 100644 --- a/src/Api.php +++ b/src/Api.php @@ -12,11 +12,14 @@ use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Builder\ErrorBuilder; +use ProgrammatorDev\Api\Builder\HookBuilder; use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener; use ProgrammatorDev\Api\Builder\LoggerBuilder; use ProgrammatorDev\Api\Builder\PluginBuilder; use ProgrammatorDev\Api\Builder\ResponseBuilder; use ProgrammatorDev\Api\Context\ErrorContext; +use ProgrammatorDev\Api\Context\RequestContext; +use ProgrammatorDev\Api\Context\ResponseContext; use ProgrammatorDev\Api\Event\PostRequestEvent; use ProgrammatorDev\Api\Event\PreRequestEvent; use ProgrammatorDev\Api\Helper\StringHelper; @@ -60,6 +63,8 @@ class Api private ErrorBuilder $errorBuilder; + private HookBuilder $hookBuilder; + private EventDispatcher $eventDispatcher; public function __construct() @@ -70,6 +75,7 @@ public function __construct() $this->pluginBuilder = new PluginBuilder(); $this->responseBuilder = new ResponseBuilder(); $this->errorBuilder = new ErrorBuilder(); + $this->hookBuilder = new HookBuilder(); $this->eventDispatcher = new EventDispatcher(); } @@ -86,6 +92,7 @@ public function send( { $options ??= new RequestOptions(); $path = $this->buildPath($path, $pathParams); + $context = new Context($this->config); $response = $this->sendRequest( method: $method, @@ -93,10 +100,10 @@ public function send( query: $options->getQuery(), headers: $options->getHeaders(), body: $options->getBody(), - options: $options + options: $options, + context: $context ); - $context = new Context($this->config); $apiResponse = new Response( data: $this->getResponseData($response), rawResponse: $response, @@ -154,6 +161,11 @@ protected function auth(): AuthBuilder return $this->authBuilder; } + public function hooks(): HookBuilder + { + return $this->hookBuilder; + } + public function plugins(): PluginBuilder { return $this->pluginBuilder; @@ -336,9 +348,12 @@ private function sendRequest( array $query = [], array $headers = [], string|StreamInterface|null $body = null, - ?RequestOptions $options = null + ?RequestOptions $options = null, + ?Context $context = null ): ResponseInterface { + $context ??= new Context($this->config); + if (!empty($this->queryDefaults)) { $query = array_merge($this->queryDefaults, $query); } @@ -351,13 +366,18 @@ private function sendRequest( $request = $this->createRequest($method, $url, $headers, $body); $plugins = $this->buildPlugins(); - // pre request listener + $request = $this->hookBuilder->applyBeforeRequestHooks( + new RequestContext($request, $context) + ); + $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); - // request $response = $this->clientBuilder->getClient($plugins)->sendRequest($request); - // post request listener + $response = $this->hookBuilder->applyAfterResponseHooks( + new ResponseContext($request, $response, $context) + ); + return $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse(); } diff --git a/src/Builder/HookBuilder.php b/src/Builder/HookBuilder.php new file mode 100644 index 0000000..7971a46 --- /dev/null +++ b/src/Builder/HookBuilder.php @@ -0,0 +1,97 @@ +> */ + private array $beforeRequestHooks = []; + + /** @var array> */ + private array $afterResponseHooks = []; + + /** + * @param callable(RequestContext): (RequestInterface|null) $hook + */ + public function beforeRequest(callable $hook, int $priority = 0): self + { + $this->beforeRequestHooks[$priority] ??= []; + $this->beforeRequestHooks[$priority][] = $hook; + + return $this; + } + + /** + * @param callable(ResponseContext): (ResponseInterface|null) $hook + */ + public function afterResponse(callable $hook, int $priority = 0): self + { + $this->afterResponseHooks[$priority] ??= []; + $this->afterResponseHooks[$priority][] = $hook; + + return $this; + } + + public function applyBeforeRequestHooks(RequestContext $context): RequestInterface + { + $request = $context->request(); + + foreach ($this->sort($this->beforeRequestHooks) as $hook) { + $replacement = $hook(new RequestContext($request, $context->apiContext())); + + if ($replacement instanceof RequestInterface) { + $request = $replacement; + + continue; + } + + if ($replacement !== null) { + throw new UnexpectedValueException('Before request hooks must return a RequestInterface instance or null.'); + } + } + + return $request; + } + + public function applyAfterResponseHooks(ResponseContext $context): ResponseInterface + { + $response = $context->response(); + + foreach ($this->sort($this->afterResponseHooks) as $hook) { + $replacement = $hook(new ResponseContext($context->request(), $response, $context->apiContext())); + + if ($replacement instanceof ResponseInterface) { + $response = $replacement; + + continue; + } + + if ($replacement !== null) { + throw new UnexpectedValueException('After response hooks must return a ResponseInterface instance or null.'); + } + } + + return $response; + } + + /** + * @param array> $hooks + * @return list + */ + private function sort(array $hooks): array + { + if ($hooks === []) { + return []; + } + + krsort($hooks); + + return array_values(array_merge(...array_values($hooks))); + } +} diff --git a/src/Context/RequestContext.php b/src/Context/RequestContext.php new file mode 100644 index 0000000..b2df489 --- /dev/null +++ b/src/Context/RequestContext.php @@ -0,0 +1,24 @@ +request; + } + + public function apiContext(): Context + { + return $this->context; + } +} diff --git a/src/Context/ResponseContext.php b/src/Context/ResponseContext.php new file mode 100644 index 0000000..652e471 --- /dev/null +++ b/src/Context/ResponseContext.php @@ -0,0 +1,31 @@ +request; + } + + public function response(): ResponseInterface + { + return $this->response; + } + + public function apiContext(): Context + { + return $this->context; + } +} diff --git a/tests/Fixture/JsonApi.php b/tests/Fixture/JsonApi.php index a2fa099..14a7a72 100644 --- a/tests/Fixture/JsonApi.php +++ b/tests/Fixture/JsonApi.php @@ -8,6 +8,8 @@ use Http\Message\RequestMatcher\RequestMatcher; use ProgrammatorDev\Api\Api; use ProgrammatorDev\Api\Context\ErrorContext; +use ProgrammatorDev\Api\Context\RequestContext; +use ProgrammatorDev\Api\Context\ResponseContext; use Psr\Http\Message\RequestInterface; class JsonApi extends Api @@ -131,6 +133,26 @@ public function usePlugin(Plugin $plugin, int $priority = 0): self return $this; } + /** + * @param callable(RequestContext): mixed $hook + */ + public function beforeRequest(callable $hook, int $priority = 0): self + { + $this->hooks()->beforeRequest($hook, $priority); + + return $this; + } + + /** + * @param callable(ResponseContext): mixed $hook + */ + public function afterResponse(callable $hook, int $priority = 0): self + { + $this->hooks()->afterResponse($hook, $priority); + + return $this; + } + public function raw(): RawResource { return $this->resource(RawResource::class); diff --git a/tests/Integration/HookTest.php b/tests/Integration/HookTest.php new file mode 100644 index 0000000..70b6ba6 --- /dev/null +++ b/tests/Integration/HookTest.php @@ -0,0 +1,113 @@ +client(); + + (new JsonApi($client)) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withHeader('X-Hook', 'before')) + ->raw() + ->fetch(); + + $this->assertSame('before', $client->getLastRequest()->getHeaderLine('X-Hook')); + } + + public function testAfterResponseHookCanReplaceResponse(): void + { + $client = $this->client(); + + $response = (new JsonApi($client)) + ->afterResponse(fn (ResponseContext $context) => new Response(status: 202, body: '{"ok":true}')) + ->raw() + ->fetch(); + + $this->assertSame(202, $response->raw()->getStatusCode()); + $this->assertSame(['ok' => true], $response->data()); + } + + public function testHookReturningNullLeavesObjectUnchanged(): void + { + $client = $this->client(); + + $response = (new JsonApi($client)) + ->beforeRequest(fn (RequestContext $context) => null) + ->afterResponse(fn (ResponseContext $context) => null) + ->raw() + ->fetch(); + + $this->assertFalse($client->getLastRequest()->hasHeader('X-Hook')); + $this->assertSame(200, $response->raw()->getStatusCode()); + $this->assertSame(['ok' => false], $response->data()); + } + + public function testHooksRunByPriorityAndInsertionOrder(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'low'), priority: 10) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'first'), priority: 20) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'second'), priority: 20) + ->raw() + ->fetch(); + + $this->assertSame(['first', 'second', 'low'], $client->getLastRequest()->getHeader('X-Hook-Order')); + } + + public function testHooksCanReadSdkConfig(): void + { + $client = $this->client(); + + $api = new JsonApi($client); + $api->config(['tenant' => 'acme']); + + $api + ->beforeRequest(fn (RequestContext $context) => $context->request()->withHeader('X-Tenant', $context->apiContext()->config()->get('tenant'))) + ->raw() + ->fetch(); + + $this->assertSame('acme', $client->getLastRequest()->getHeaderLine('X-Tenant')); + } + + public function testBeforeRequestHookRejectsInvalidReturnValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Before request hooks must return a RequestInterface instance or null.'); + + (new JsonApi($this->client())) + ->beforeRequest(fn (RequestContext $context) => 'invalid') + ->raw() + ->fetch(); + } + + public function testAfterResponseHookRejectsInvalidReturnValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('After response hooks must return a ResponseInterface instance or null.'); + + (new JsonApi($this->client())) + ->afterResponse(fn (ResponseContext $context) => 'invalid') + ->raw() + ->fetch(); + } + + private function client(): Client + { + $client = new Client(); + $client->addResponse(new Response(body: '{"ok":false}')); + + return $client; + } +} From 6299d2ab6381c30f5499957fd1276f7115cddac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 11:31:09 +0100 Subject: [PATCH 30/88] refactor(v3): remove legacy request events --- composer.json | 3 +-- docs/hooks.md | 4 ---- docs/v3-architecture-plan.md | 31 ++++++++++++------------------- src/Api.php | 24 +----------------------- src/Event/PostRequestEvent.php | 30 ------------------------------ src/Event/PreRequestEvent.php | 23 ----------------------- 6 files changed, 14 insertions(+), 101 deletions(-) delete mode 100644 src/Event/PostRequestEvent.php delete mode 100644 src/Event/PreRequestEvent.php diff --git a/composer.json b/composer.json index 280be3a..6e42f7f 100644 --- a/composer.json +++ b/composer.json @@ -24,8 +24,7 @@ "psr/http-client-implementation": "*", "psr/http-factory": "^1.1", "psr/http-factory-implementation": "*", - "psr/log": "^2.0|^3.0", - "symfony/event-dispatcher": "^6.4|^7.4|^8.0" + "psr/log": "^2.0|^3.0" }, "require-dev": { "monolog/monolog": "^3.9", diff --git a/docs/hooks.md b/docs/hooks.md index af8d3c5..b3e59e1 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -89,14 +89,10 @@ The current v3 request flow is: ```text create request beforeRequest hooks -legacy pre-request events send request afterResponse hooks -legacy post-request events decode response create Response errors return Response ``` - -The legacy event layer is still present temporarily while v3 hooks settle. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 70c56ac..6a61f4c 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -204,17 +204,15 @@ Current public methods: - `plugins` - `cache` - `config` -- `addPreRequestListener` -- `addPostRequestListener` -Observations: +Original v2 observations: -- The current `Api` class is easy to extend but exposes low-level request execution directly. -- SDK packages currently use `request` from resources. -- Defaults are global to the API instance, which makes resource-level fluent options awkward. -- JSON decoding and error handling are implemented through listeners. -- Plugin configuration is automatic inside `request`, which can duplicate responsibilities and makes request flow harder to reason about. -- Symfony EventDispatcher provides flexibility, but common behavior like JSON decoding and error mapping should not require event listeners in v3. +- The v2 `Api` class is easy to extend but exposes low-level request execution directly. +- SDK packages used `request` from resources. +- Defaults were global to the API instance, which made resource-level fluent options awkward. +- JSON decoding and error handling were commonly implemented through listeners. +- Plugin configuration was automatic inside `request`, which duplicated responsibilities and made request flow harder to reason about. +- Symfony EventDispatcher provided flexibility, but common behavior like JSON decoding and error mapping should not require event listeners in v3. ### Builders @@ -237,26 +235,21 @@ These capabilities must remain available in v3. HTTPlug's `PluginClientBuilder` already supports priority ordering and multiple plugins at the same priority. v3 should use that behavior directly or mirror it closely. The current v2 `ClientBuilder` stores plugins as `[priority => plugin]`, which means plugins with the same priority overwrite each other. -### Events +### Hooks -Current event classes: - -- `PreRequestEvent` -- `PostRequestEvent` - -Current capabilities: +Current hook capabilities: - Mutate request before sending. - Mutate response after sending. -These capabilities should remain, but v3 should consider clearer first-class APIs for common cases: +These capabilities remain through first-class APIs: - JSON decoding. - Error mapping. - Request hooks. - Response hooks. -Decision: v3 should replace the Symfony EventDispatcher dependency with a smaller request/response pipeline. The pipeline should still support request hooks, response hooks, and response transformation, but common features should be first-class fluent APIs. +Decision: v3 replaces the Symfony EventDispatcher dependency with a smaller request/response pipeline. The pipeline supports request hooks and response hooks, while common features are first-class fluent APIs. Hooks should receive lightweight context objects rather than long argument lists: @@ -347,7 +340,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - `Response::as()` should require a `ResponseEnvelope` contract with `fromResponse(Response $response, ?Context $context = null)`. - `Response::collection()` should return a plain array by default. - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. -- Symfony EventDispatcher should be replaced with a smaller request/response pipeline. +- Symfony EventDispatcher has been replaced with a smaller request/response pipeline. - v3-native hooks are represented by `HookBuilder`, `RequestContext`, and `ResponseContext`. - Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. - Prefer fluent configuration over public getters. diff --git a/src/Api.php b/src/Api.php index 0bfdcb6..050b47a 100644 --- a/src/Api.php +++ b/src/Api.php @@ -20,8 +20,6 @@ use ProgrammatorDev\Api\Context\ErrorContext; use ProgrammatorDev\Api\Context\RequestContext; use ProgrammatorDev\Api\Context\ResponseContext; -use ProgrammatorDev\Api\Event\PostRequestEvent; -use ProgrammatorDev\Api\Event\PreRequestEvent; use ProgrammatorDev\Api\Helper\StringHelper; use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface as ClientException; @@ -31,7 +29,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; -use Symfony\Component\EventDispatcher\EventDispatcher; class Api { @@ -65,8 +62,6 @@ class Api private HookBuilder $hookBuilder; - private EventDispatcher $eventDispatcher; - public function __construct() { $this->config = new Config(); @@ -76,7 +71,6 @@ public function __construct() $this->responseBuilder = new ResponseBuilder(); $this->errorBuilder = new ErrorBuilder(); $this->hookBuilder = new HookBuilder(); - $this->eventDispatcher = new EventDispatcher(); } /** @@ -277,20 +271,6 @@ private function buildLoggerPlugin(): ?Plugin ); } - public function addPreRequestListener(callable $listener, int $priority = 0): self - { - $this->eventDispatcher->addListener(PreRequestEvent::class, $listener, $priority); - - return $this; - } - - public function addPostRequestListener(callable $listener, int $priority = 0): self - { - $this->eventDispatcher->addListener(PostRequestEvent::class, $listener, $priority); - - return $this; - } - private function buildPath(string $path, array $parameters): string { foreach ($parameters as $parameter => $value) { @@ -370,15 +350,13 @@ private function sendRequest( new RequestContext($request, $context) ); - $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); - $response = $this->clientBuilder->getClient($plugins)->sendRequest($request); $response = $this->hookBuilder->applyAfterResponseHooks( new ResponseContext($request, $response, $context) ); - return $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse(); + return $response; } private function getResponseData(ResponseInterface $response): mixed diff --git a/src/Event/PostRequestEvent.php b/src/Event/PostRequestEvent.php deleted file mode 100644 index d5c1f74..0000000 --- a/src/Event/PostRequestEvent.php +++ /dev/null @@ -1,30 +0,0 @@ -request; - } - - public function getResponse(): ResponseInterface - { - return $this->response; - } - - public function setResponse(ResponseInterface $response): void - { - $this->response = $response; - } -} \ No newline at end of file diff --git a/src/Event/PreRequestEvent.php b/src/Event/PreRequestEvent.php deleted file mode 100644 index cd01e78..0000000 --- a/src/Event/PreRequestEvent.php +++ /dev/null @@ -1,23 +0,0 @@ -request; - } - - public function setRequest(RequestInterface $request): void - { - $this->request = $request; - } -} \ No newline at end of file From ba490f9922aa1a285e9357bc8ceeb0e0033c7838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 11:32:36 +0100 Subject: [PATCH 31/88] build(deps): update package constraints --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 6e42f7f..6eccde2 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "require": { "php": ">=8.1", "nyholm/append-query-string": "^1.0", - "php-http/cache-plugin": "^2.0", + "php-http/cache-plugin": "^2.1", "php-http/client-common": "^2.7", "php-http/discovery": "^1.20", "php-http/logger-plugin": "^1.3", @@ -27,7 +27,7 @@ "psr/log": "^2.0|^3.0" }, "require-dev": { - "monolog/monolog": "^3.9", + "monolog/monolog": "^3.10", "nyholm/psr7": "^1.8", "php-http/mock-client": "^1.6", "phpunit/phpunit": "^10.5", From 3ba7116793393e00076986acddb5d106121f4da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 11:45:52 +0100 Subject: [PATCH 32/88] refactor(v3): extract HTTP transport --- docs/api.md | 12 ++ docs/v3-architecture-plan.md | 2 +- src/Api.php | 197 ++----------------------------- src/Transport.php | 215 ++++++++++++++++++++++++++++++++++ tests/Integration/ApiTest.php | 15 +++ 5 files changed, 256 insertions(+), 185 deletions(-) create mode 100644 src/Transport.php diff --git a/docs/api.md b/docs/api.md index 7dc31c6..fe70795 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,6 +4,18 @@ Methods not listed here are legacy, internal, or still being reshaped for v3. +## `send(string $method, string $path, array $pathParams = [], ?RequestOptions $options = null): Response` + +Public low-level request helper. + +Most SDK methods should use resources and the protected resource verb helpers. `send()` is useful when an SDK author or advanced SDK user needs to execute a request directly while still using the configured base URL, defaults, auth, plugins, cache, hooks, decoding, and errors. + +```php +$response = $api->send('GET', '/users/{id}', ['id' => 1]); +``` + +Path parameters are encoded and replaced in `{name}` placeholders. + ## `config(?array $values = null): Config` Public. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 6a61f4c..e7911e1 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -311,7 +311,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon | v2 capability | Proposed v3 shape | | --- | --- | -| Public `Api::request` | Removed; protected/internal transport used by `Resource` helpers | +| Public `Api::request` | Removed; public `Api::send()` delegates HTTP mechanics to internal `Transport` | | `Api::buildPath` | Path parameter replacement inside `Resource`/transport `get('/x/{id}', ['id' => $id])` | | `setBaseUrl` / `getBaseUrl` | Fluent `baseUrl(...)`, optional getter only if useful | | SDK-specific global options | Generic config bag exposed to resources/responses/entities through context | diff --git a/src/Api.php b/src/Api.php index 050b47a..56845a3 100644 --- a/src/Api.php +++ b/src/Api.php @@ -2,42 +2,24 @@ namespace ProgrammatorDev\Api; -use Http\Client\Common\Plugin; -use Http\Client\Common\Plugin\AuthenticationPlugin; -use Http\Client\Common\Plugin\CachePlugin; -use Http\Client\Common\Plugin\ContentLengthPlugin; -use Http\Client\Common\Plugin\ContentTypePlugin; -use Http\Client\Common\Plugin\LoggerPlugin; use ProgrammatorDev\Api\Builder\AuthBuilder; use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; use ProgrammatorDev\Api\Builder\ErrorBuilder; use ProgrammatorDev\Api\Builder\HookBuilder; -use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener; use ProgrammatorDev\Api\Builder\LoggerBuilder; use ProgrammatorDev\Api\Builder\PluginBuilder; use ProgrammatorDev\Api\Builder\ResponseBuilder; use ProgrammatorDev\Api\Context\ErrorContext; -use ProgrammatorDev\Api\Context\RequestContext; -use ProgrammatorDev\Api\Context\ResponseContext; -use ProgrammatorDev\Api\Helper\StringHelper; use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface as ClientException; use Psr\Http\Client\ClientInterface; use Psr\Cache\CacheItemPoolInterface; -use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; class Api { - private const CONTENT_TYPE_PLUGIN_PRIORITY = 50; - private const CONTENT_LENGTH_PLUGIN_PRIORITY = 40; - private const AUTHENTICATION_PLUGIN_PRIORITY = 30; - private const CACHE_PLUGIN_PRIORITY = 20; - private const LOGGER_PLUGIN_PRIORITY = 10; - private ?string $baseUrl = null; private array $queryDefaults = []; @@ -74,7 +56,6 @@ public function __construct() } /** - * @internal * @throws ClientException */ public function send( @@ -85,15 +66,12 @@ public function send( ): Response { $options ??= new RequestOptions(); - $path = $this->buildPath($path, $pathParams); $context = new Context($this->config); - $response = $this->sendRequest( + $response = $this->transport()->send( method: $method, path: $path, - query: $options->getQuery(), - headers: $options->getHeaders(), - body: $options->getBody(), + pathParams: $pathParams, options: $options, context: $context ); @@ -195,168 +173,19 @@ public function config(?array $values = null): Config return $this->config; } - private function buildPlugins(): array - { - $plugins = new PluginBuilder(); - - $plugins->add( - plugin: new ContentTypePlugin(), - priority: self::CONTENT_TYPE_PLUGIN_PRIORITY - ); - - $plugins->add( - plugin: new ContentLengthPlugin(), - priority: self::CONTENT_LENGTH_PLUGIN_PRIORITY - ); - - if ($authentication = $this->authBuilder->authentication()) { - $plugins->add( - plugin: new AuthenticationPlugin($authentication), - priority: self::AUTHENTICATION_PLUGIN_PRIORITY - ); - } - - if ($cachePlugin = $this->buildCachePlugin()) { - $plugins->add( - plugin: $cachePlugin, - priority: self::CACHE_PLUGIN_PRIORITY - ); - } - - if ($loggerPlugin = $this->buildLoggerPlugin()) { - $plugins->add( - plugin: $loggerPlugin, - priority: self::LOGGER_PLUGIN_PRIORITY - ); - } - - $plugins->merge($this->pluginBuilder); - - return $plugins->all(); - } - - private function buildCachePlugin(): ?Plugin - { - if ($this->cacheBuilder === null) { - return null; - } - - $cacheOptions = [ - 'default_ttl' => $this->cacheBuilder->getDefaultTtl(), - 'methods' => $this->cacheBuilder->getMethods(), - 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), - 'cache_listeners' => [] - ]; - - if ($this->loggerBuilder) { - $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); - } - - return new CachePlugin( - $this->cacheBuilder->getPool(), - $this->clientBuilder->getStreamFactory(), - $cacheOptions - ); - } - - private function buildLoggerPlugin(): ?Plugin - { - if ($this->loggerBuilder === null) { - return null; - } - - return new LoggerPlugin( - $this->loggerBuilder->getLogger(), - $this->loggerBuilder->getFormatter() - ); - } - - private function buildPath(string $path, array $parameters): string - { - foreach ($parameters as $parameter => $value) { - $path = str_replace( - sprintf('{%s}', $parameter), - rawurlencode((string) $value), - $path - ); - } - - return $path; - } - - private function buildUrl(string $path, array $query = []): string - { - $query = array_filter($query, static fn(mixed $value): bool => $value !== null); - $appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); - - if (StringHelper::isUrl($path)) { - return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); - } - - $url = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); - return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); - } - - private function createRequest( - string $method, - string $url, - array $headers = [], - string|StreamInterface|null $body = null - ): RequestInterface - { - $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url); - - foreach ($headers as $key => $value) { - $request = $request->withHeader($key, $value); - } - - if ($body !== null && $body !== '') { - $request = $request->withBody( - is_string($body) ? $this->clientBuilder->getStreamFactory()->createStream($body) : $body - ); - } - - return $request; - } - - /** - * @throws ClientException - */ - private function sendRequest( - string $method, - string $path, - array $query = [], - array $headers = [], - string|StreamInterface|null $body = null, - ?RequestOptions $options = null, - ?Context $context = null - ): ResponseInterface + private function transport(): Transport { - $context ??= new Context($this->config); - - if (!empty($this->queryDefaults)) { - $query = array_merge($this->queryDefaults, $query); - } - - if (!empty($this->headerDefaults)) { - $headers = array_merge($this->headerDefaults, $headers); - } - - $url = $this->buildUrl($path, $query); - $request = $this->createRequest($method, $url, $headers, $body); - $plugins = $this->buildPlugins(); - - $request = $this->hookBuilder->applyBeforeRequestHooks( - new RequestContext($request, $context) + return new Transport( + clientBuilder: $this->clientBuilder, + authBuilder: $this->authBuilder, + pluginBuilder: $this->pluginBuilder, + hookBuilder: $this->hookBuilder, + cacheBuilder: $this->cacheBuilder, + loggerBuilder: $this->loggerBuilder, + baseUrl: $this->baseUrl, + queryDefaults: $this->queryDefaults, + headerDefaults: $this->headerDefaults ); - - $response = $this->clientBuilder->getClient($plugins)->sendRequest($request); - - $response = $this->hookBuilder->applyAfterResponseHooks( - new ResponseContext($request, $response, $context) - ); - - return $response; } private function getResponseData(ResponseInterface $response): mixed diff --git a/src/Transport.php b/src/Transport.php new file mode 100644 index 0000000..578aee8 --- /dev/null +++ b/src/Transport.php @@ -0,0 +1,215 @@ +buildPath($path, $pathParams); + $query = $options->getQuery(); + $headers = $options->getHeaders(); + + if (!empty($this->queryDefaults)) { + $query = array_merge($this->queryDefaults, $query); + } + + if (!empty($this->headerDefaults)) { + $headers = array_merge($this->headerDefaults, $headers); + } + + $request = $this->createRequest( + method: $method, + url: $this->buildUrl($path, $query), + headers: $headers, + body: $options->getBody() + ); + + $request = $this->hookBuilder->applyBeforeRequestHooks( + new RequestContext($request, $context) + ); + + $response = $this->clientBuilder + ->getClient($this->buildPlugins()) + ->sendRequest($request); + + return $this->hookBuilder->applyAfterResponseHooks( + new ResponseContext($request, $response, $context) + ); + } + + private function buildPlugins(): array + { + $plugins = new PluginBuilder(); + + $plugins->add( + plugin: new ContentTypePlugin(), + priority: self::CONTENT_TYPE_PLUGIN_PRIORITY + ); + + $plugins->add( + plugin: new ContentLengthPlugin(), + priority: self::CONTENT_LENGTH_PLUGIN_PRIORITY + ); + + if ($authentication = $this->authBuilder->authentication()) { + $plugins->add( + plugin: new AuthenticationPlugin($authentication), + priority: self::AUTHENTICATION_PLUGIN_PRIORITY + ); + } + + if ($cachePlugin = $this->buildCachePlugin()) { + $plugins->add( + plugin: $cachePlugin, + priority: self::CACHE_PLUGIN_PRIORITY + ); + } + + if ($loggerPlugin = $this->buildLoggerPlugin()) { + $plugins->add( + plugin: $loggerPlugin, + priority: self::LOGGER_PLUGIN_PRIORITY + ); + } + + $plugins->merge($this->pluginBuilder); + + return $plugins->all(); + } + + private function buildCachePlugin(): ?Plugin + { + if ($this->cacheBuilder === null) { + return null; + } + + $cacheOptions = [ + 'default_ttl' => $this->cacheBuilder->getDefaultTtl(), + 'methods' => $this->cacheBuilder->getMethods(), + 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), + 'cache_listeners' => [] + ]; + + if ($this->loggerBuilder) { + $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); + } + + return new CachePlugin( + $this->cacheBuilder->getPool(), + $this->clientBuilder->getStreamFactory(), + $cacheOptions + ); + } + + private function buildLoggerPlugin(): ?Plugin + { + if ($this->loggerBuilder === null) { + return null; + } + + return new LoggerPlugin( + $this->loggerBuilder->getLogger(), + $this->loggerBuilder->getFormatter() + ); + } + + private function buildPath(string $path, array $parameters): string + { + foreach ($parameters as $parameter => $value) { + $path = str_replace( + sprintf('{%s}', $parameter), + rawurlencode((string) $value), + $path + ); + } + + return $path; + } + + private function buildUrl(string $path, array $query = []): string + { + $query = array_filter($query, static fn(mixed $value): bool => $value !== null); + $appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + if (StringHelper::isUrl($path)) { + return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); + } + + $url = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); + return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); + } + + private function createRequest( + string $method, + string $url, + array $headers = [], + string|StreamInterface|null $body = null + ): RequestInterface + { + $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url); + + foreach ($headers as $key => $value) { + $request = $request->withHeader($key, $value); + } + + if ($body !== null && $body !== '') { + $request = $request->withBody( + is_string($body) ? $this->clientBuilder->getStreamFactory()->createStream($body) : $body + ); + } + + return $request; + } +} diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 5f2c2c0..cadaf21 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -2,7 +2,11 @@ namespace ProgrammatorDev\Api\Test\Integration; +use Http\Mock\Client; +use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Api; +use ProgrammatorDev\Api\Method; +use ProgrammatorDev\Api\Test\Fixture\FakeApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; class ApiTest extends AbstractTestCase @@ -23,4 +27,15 @@ public function testConfigCanBeSetAndReadBySdkApi(): void 'units' => 'metric', ], $api->config()->all()); } + + public function testApiCanSendPublicRequest(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $response = (new FakeApi($client))->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); + } } From c75a97960eff9d60c5c59cf7a7ee9c2ea2d1bac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 12:01:54 +0100 Subject: [PATCH 33/88] feat(v3): add response decoder formats --- composer.json | 1 + docs/api.md | 15 +-- docs/responses.md | 6 +- docs/v3-architecture-plan.md | 1 + src/Api.php | 28 +++--- src/Builder/ErrorBuilder.php | 3 + src/Builder/HookBuilder.php | 6 ++ src/Builder/ResponseBuilder.php | 50 +++++++++- src/ResponseDecoder.php | 77 +++++++++++++++ src/ResponseFormat.php | 11 +++ src/Transport.php | 5 +- tests/Fixture/XmlApi.php | 26 ++++++ tests/Integration/ResponseDecodingTest.php | 34 +++++++ tests/Unit/ResponseDecoderTest.php | 104 +++++++++++++++++++++ 14 files changed, 336 insertions(+), 31 deletions(-) create mode 100644 src/ResponseDecoder.php create mode 100644 src/ResponseFormat.php create mode 100644 tests/Fixture/XmlApi.php create mode 100644 tests/Unit/ResponseDecoderTest.php diff --git a/composer.json b/composer.json index 6eccde2..ac39c49 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ ], "require": { "php": ">=8.1", + "ext-simplexml": "*", "nyholm/append-query-string": "^1.0", "php-http/cache-plugin": "^2.1", "php-http/client-common": "^2.7", diff --git a/docs/api.md b/docs/api.md index fe70795..51eb13a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -172,17 +172,18 @@ Protected access to response decoding configuration. ```php $this->responses()->json(); +$this->responses()->xml(); +$this->responses()->custom($decoder); ``` -When JSON decoding is enabled: +Available response formats: -- JSON response bodies are decoded into arrays. -- Empty response bodies become `null`. -- Invalid JSON throws `JsonException`. +- `raw()`: response bodies are returned as strings. +- `json()`: response bodies are decoded into arrays; empty bodies become `null`; invalid JSON throws `JsonException`. +- `xml()`: response bodies are decoded into `SimpleXMLElement`; empty bodies become `null`; invalid XML throws `RuntimeException`. +- `custom()`: receives the raw PSR response and returns the value used as `Response::data()`. -When JSON decoding is not enabled, `Response::data()` returns the raw response body string. - -This area will grow as response transforms and errors are finalized. +When no format is configured, `raw()` is used. ## `errors(): ErrorBuilder` diff --git a/docs/responses.md b/docs/responses.md index a56d838..269b301 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -12,7 +12,11 @@ Returns response data. When `responses()->json()` is enabled on the API, JSON bodies are decoded into arrays, empty bodies become `null`, and invalid JSON throws `JsonException`. -When JSON decoding is not enabled, this returns the raw response body string. +When `responses()->xml()` is enabled, XML bodies are decoded into `SimpleXMLElement`, empty bodies become `null`, and invalid XML throws `RuntimeException`. + +When `responses()->custom()` is enabled, the configured callable receives the raw PSR response and returns the value used as `Response::data()`. + +When no response format is configured, this returns the raw response body string. ```php $data = $response->data(); diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index e7911e1..a141774 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -342,6 +342,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. - Symfony EventDispatcher has been replaced with a smaller request/response pipeline. - v3-native hooks are represented by `HookBuilder`, `RequestContext`, and `ResponseContext`. +- Response body decoding is represented by `ResponseDecoder` and `ResponseFormat`; transport returns raw PSR responses and does not decode. Common formats use `raw()`, `json()`, and `xml()`, while custom response decoding uses `custom()`. - Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. - Prefer fluent configuration over public getters. - Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. diff --git a/src/Api.php b/src/Api.php index 56845a3..deea485 100644 --- a/src/Api.php +++ b/src/Api.php @@ -2,6 +2,7 @@ namespace ProgrammatorDev\Api; +use JsonException; use ProgrammatorDev\Api\Builder\AuthBuilder; use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; @@ -12,11 +13,12 @@ use ProgrammatorDev\Api\Builder\ResponseBuilder; use ProgrammatorDev\Api\Context\ErrorContext; use ProgrammatorDev\Api\Request\RequestOptions; -use Psr\Http\Client\ClientExceptionInterface as ClientException; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Cache\CacheItemPoolInterface; -use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; class Api { @@ -56,7 +58,10 @@ public function __construct() } /** - * @throws ClientException + * @throws ClientExceptionInterface + * @throws JsonException + * @throws RuntimeException + * @throws Throwable */ public function send( string $method, @@ -77,7 +82,7 @@ public function send( ); $apiResponse = new Response( - data: $this->getResponseData($response), + data: $this->responseDecoder()->decode($response), rawResponse: $response, context: $context ); @@ -188,19 +193,8 @@ private function transport(): Transport ); } - private function getResponseData(ResponseInterface $response): mixed + private function responseDecoder(): ResponseDecoder { - $response->getBody()->rewind(); - $contents = $response->getBody()->getContents(); - - if (!$this->responseBuilder->shouldDecodeJson()) { - return $contents; - } - - if ($contents === '') { - return null; - } - - return json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + return new ResponseDecoder($this->responseBuilder); } } diff --git a/src/Builder/ErrorBuilder.php b/src/Builder/ErrorBuilder.php index a293f08..bc0576e 100644 --- a/src/Builder/ErrorBuilder.php +++ b/src/Builder/ErrorBuilder.php @@ -51,6 +51,9 @@ public function when(callable $handler): self return $this; } + /** + * @throws \Throwable + */ public function throwIfMatched(ErrorContext $context): void { $handler = $this->statusHandlers[$context->statusCode()] ?? null; diff --git a/src/Builder/HookBuilder.php b/src/Builder/HookBuilder.php index 7971a46..6a13426 100644 --- a/src/Builder/HookBuilder.php +++ b/src/Builder/HookBuilder.php @@ -38,6 +38,9 @@ public function afterResponse(callable $hook, int $priority = 0): self return $this; } + /** + * @throws UnexpectedValueException + */ public function applyBeforeRequestHooks(RequestContext $context): RequestInterface { $request = $context->request(); @@ -59,6 +62,9 @@ public function applyBeforeRequestHooks(RequestContext $context): RequestInterfa return $request; } + /** + * @throws UnexpectedValueException + */ public function applyAfterResponseHooks(ResponseContext $context): ResponseInterface { $response = $context->response(); diff --git a/src/Builder/ResponseBuilder.php b/src/Builder/ResponseBuilder.php index 6cdcf35..d3e58f8 100644 --- a/src/Builder/ResponseBuilder.php +++ b/src/Builder/ResponseBuilder.php @@ -2,19 +2,61 @@ namespace ProgrammatorDev\Api\Builder; +use ProgrammatorDev\Api\ResponseFormat; +use Psr\Http\Message\ResponseInterface; + class ResponseBuilder { - private bool $decodeJson = false; + private ResponseFormat $format = ResponseFormat::Raw; + + /** @var null|callable(ResponseInterface): mixed */ + private $customDecoder = null; + + public function raw(): self + { + $this->format = ResponseFormat::Raw; + $this->customDecoder = null; + + return $this; + } public function json(): self { - $this->decodeJson = true; + $this->format = ResponseFormat::Json; + $this->customDecoder = null; + + return $this; + } + + public function xml(): self + { + $this->format = ResponseFormat::Xml; + $this->customDecoder = null; + + return $this; + } + + /** + * @param callable(ResponseInterface): mixed $decoder + */ + public function custom(callable $decoder): self + { + $this->format = ResponseFormat::Custom; + $this->customDecoder = $decoder; return $this; } - public function shouldDecodeJson(): bool + public function format(): ResponseFormat + { + return $this->format; + } + + /** + * @return null|callable(ResponseInterface): mixed + */ + public function customDecoder(): ?callable { - return $this->decodeJson; + return $this->customDecoder; } } diff --git a/src/ResponseDecoder.php b/src/ResponseDecoder.php new file mode 100644 index 0000000..6113aef --- /dev/null +++ b/src/ResponseDecoder.php @@ -0,0 +1,77 @@ +getBody()->rewind(); + $contents = $response->getBody()->getContents(); + + return match ($this->responseBuilder->format()) { + ResponseFormat::Raw => $contents, + ResponseFormat::Json => $this->decodeJson($contents), + ResponseFormat::Xml => $this->decodeXml($contents), + ResponseFormat::Custom => $this->decodeCustom($response), + }; + } + + /** + * @throws \JsonException + */ + private function decodeJson(string $contents): mixed + { + return $contents === '' + ? null + : json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } + + private function decodeXml(string $contents): ?SimpleXMLElement + { + if ($contents === '') { + return null; + } + + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + + $xml = simplexml_load_string($contents); + $errors = libxml_get_errors(); + + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if (!$xml instanceof SimpleXMLElement) { + $message = $errors[0]->message ?? 'Invalid XML response body.'; + + throw new RuntimeException(trim($message)); + } + + return $xml; + } + + private function decodeCustom(ResponseInterface $response): mixed + { + $decoder = $this->responseBuilder->customDecoder(); + + if ($decoder === null) { + throw new RuntimeException('A custom response decoder must be configured.'); + } + + return $decoder($response); + } +} diff --git a/src/ResponseFormat.php b/src/ResponseFormat.php new file mode 100644 index 0000000..7392fc3 --- /dev/null +++ b/src/ResponseFormat.php @@ -0,0 +1,11 @@ +client($client); + + $this + ->baseUrl('https://api.example.com') + ->responses() + ->xml(); + } + + public function raw(): RawResource + { + return $this->resource(RawResource::class); + } +} diff --git a/tests/Integration/ResponseDecodingTest.php b/tests/Integration/ResponseDecodingTest.php index 8e41ca1..aacd23c 100644 --- a/tests/Integration/ResponseDecodingTest.php +++ b/tests/Integration/ResponseDecodingTest.php @@ -6,7 +6,9 @@ use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\JsonApi; use ProgrammatorDev\Api\Test\Fixture\PlainApi; +use ProgrammatorDev\Api\Test\Fixture\XmlApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; +use SimpleXMLElement; class ResponseDecodingTest extends AbstractTestCase { @@ -49,4 +51,36 @@ public function testInvalidJsonThrowsWhenJsonDecodingIsEnabled(): void (new JsonApi($client))->raw()->fetch(); } + + public function testResponseDataIsDecodedWhenXmlDecodingIsEnabled(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '1John')); + + $response = (new XmlApi($client))->raw()->fetch(); + + $this->assertInstanceOf(SimpleXMLElement::class, $response->data()); + $this->assertSame('1', (string) $response->data()->id); + $this->assertSame('John', (string) $response->data()->name); + } + + public function testEmptyXmlResponseBodyDecodesToNull(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '')); + + $response = (new XmlApi($client))->raw()->fetch(); + + $this->assertNull($response->data()); + } + + public function testInvalidXmlThrowsWhenXmlDecodingIsEnabled(): void + { + $client = new Client(); + $client->addResponse(new Response(body: 'expectException(\RuntimeException::class); + + (new XmlApi($client))->raw()->fetch(); + } } diff --git a/tests/Unit/ResponseDecoderTest.php b/tests/Unit/ResponseDecoderTest.php new file mode 100644 index 0000000..ec105fc --- /dev/null +++ b/tests/Unit/ResponseDecoderTest.php @@ -0,0 +1,104 @@ +assertSame('{"ok":true}', $decoder->decode(new Response(body: '{"ok":true}'))); + } + + public function testDecodeReturnsArrayWhenJsonDecodingIsEnabled(): void + { + $builder = (new ResponseBuilder())->json(); + $decoder = new ResponseDecoder($builder); + + $this->assertSame(['ok' => true], $decoder->decode(new Response(body: '{"ok":true}'))); + } + + public function testDecodeReturnsNullForEmptyJsonBody(): void + { + $builder = (new ResponseBuilder())->json(); + $decoder = new ResponseDecoder($builder); + + $this->assertNull($decoder->decode(new Response(body: ''))); + } + + public function testDecodeThrowsForInvalidJsonBody(): void + { + $builder = (new ResponseBuilder())->json(); + $decoder = new ResponseDecoder($builder); + + $this->expectException(\JsonException::class); + + $decoder->decode(new Response(body: '{invalid-json')); + } + + public function testDecodeReturnsXmlElementWhenXmlDecodingIsEnabled(): void + { + $builder = (new ResponseBuilder())->xml(); + $decoder = new ResponseDecoder($builder); + + $data = $decoder->decode(new Response(body: '1John')); + + $this->assertInstanceOf(SimpleXMLElement::class, $data); + $this->assertSame('1', (string) $data->id); + $this->assertSame('John', (string) $data->name); + } + + public function testDecodeReturnsNullForEmptyXmlBody(): void + { + $builder = (new ResponseBuilder())->xml(); + $decoder = new ResponseDecoder($builder); + + $this->assertNull($decoder->decode(new Response(body: ''))); + } + + public function testDecodeThrowsForInvalidXmlBody(): void + { + $builder = (new ResponseBuilder())->xml(); + $decoder = new ResponseDecoder($builder); + + $this->expectException(RuntimeException::class); + + $decoder->decode(new Response(body: 'custom(function (Response $response): array { + return [ + 'status' => $response->getStatusCode(), + 'body' => (string) $response->getBody(), + ]; + }); + + $decoder = new ResponseDecoder($builder); + + $this->assertSame([ + 'status' => 202, + 'body' => 'accepted', + ], $decoder->decode(new Response(status: 202, body: 'accepted'))); + } + + public function testChangingFormatClearsCustomDecoder(): void + { + $builder = (new ResponseBuilder()) + ->custom(fn (Response $response): string => 'custom') + ->raw(); + + $decoder = new ResponseDecoder($builder); + + $this->assertSame('raw-body', $decoder->decode(new Response(body: 'raw-body'))); + } +} From 4d2dc888fc9e421502fb3c33ffbe1e64fcf1420e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 12:07:36 +0100 Subject: [PATCH 34/88] refactor(v3): suffix response contracts --- docs/api-reference.md | 2 +- docs/getting-started.md | 10 +++---- docs/resource-authoring.md | 28 +++++++++++++------ docs/responses.md | 16 +++++------ docs/v3-architecture-plan.md | 16 +++++------ src/Api.php | 9 ++---- src/{Entity.php => EntityInterface.php} | 2 +- src/Response.php | 20 ++++++------- ...lope.php => ResponseEnvelopeInterface.php} | 2 +- tests/Fixture/User.php | 4 +-- tests/Fixture/UserEnvelope.php | 4 +-- 11 files changed, 60 insertions(+), 53 deletions(-) rename src/{Entity.php => EntityInterface.php} (83%) rename src/{ResponseEnvelope.php => ResponseEnvelopeInterface.php} (79%) diff --git a/docs/api-reference.md b/docs/api-reference.md index 112b6e0..81208d3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,4 +8,4 @@ This reference is split by where methods are available. - [Plugins](plugins.md): `PluginBuilder` helpers and internal plugin order. - [Hooks](hooks.md): `HookBuilder`, request hooks, response hooks, and hook contexts. - [Resources](resources.md): resource modifiers and protected request helpers. -- [Responses](responses.md): `Response`, `Entity`, `ResponseEnvelope`, and `Context`. +- [Responses](responses.md): `Response`, `EntityInterface`, `ResponseEnvelopeInterface`, and `Context`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 82f4043..228b4c8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -46,13 +46,13 @@ $user = $api->users()->find(1); ## Create An Entity -Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `Entity`. +Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `EntityInterface`. ```php use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\Entity; +use ProgrammatorDev\Api\EntityInterface; -final class User implements Entity +final class User implements EntityInterface { public function __construct( public readonly int $id, @@ -133,9 +133,9 @@ If an API returns metadata, pagination, or any custom envelope, create a respons ```php use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Response; -use ProgrammatorDev\Api\ResponseEnvelope; +use ProgrammatorDev\Api\ResponseEnvelopeInterface; -final class UserResponse implements ResponseEnvelope +final class UserResponse implements ResponseEnvelopeInterface { public function __construct( public readonly User $user, diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index 14bd771..e73ca0a 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -137,12 +137,13 @@ return $this ->entity(User::class); ``` -Entities must implement `Entity`: +Entities must implement `EntityInterface`: ```php use ProgrammatorDev\Api\Context; +use ProgrammatorDev\Api\EntityInterface; -final class User implements Entity +final class User implements EntityInterface { public static function fromArray(array $data, ?Context $context = null): static { @@ -179,7 +180,7 @@ return $this The flow is: ```text -SDK constructor options -> Api config -> Context -> Entity or ResponseEnvelope +SDK constructor options -> Api config -> Context -> EntityInterface or ResponseEnvelopeInterface ``` Start by accepting SDK options and storing them in config: @@ -202,13 +203,16 @@ final class ExampleApi extends Api When a response is mapped, the API creates a context with that config. The same context is passed to: -- `Entity::fromArray(array $data, ?Context $context = null)` -- `ResponseEnvelope::fromResponse(Response $response, ?Context $context = null)` +- `EntityInterface::fromArray(array $data, ?Context $context = null)` +- `ResponseEnvelopeInterface::fromResponse(Response $response, ?Context $context = null)` Entities can use config values during hydration: ```php -final class User implements Entity +use ProgrammatorDev\Api\Context; +use ProgrammatorDev\Api\EntityInterface; + +final class User implements EntityInterface { public function __construct( private readonly int $id, @@ -230,7 +234,11 @@ final class User implements Entity Response envelopes receive the same context: ```php -final class UserResponse implements ResponseEnvelope +use ProgrammatorDev\Api\Context; +use ProgrammatorDev\Api\Response; +use ProgrammatorDev\Api\ResponseEnvelopeInterface; + +final class UserResponse implements ResponseEnvelopeInterface { public function __construct( private readonly User $user, @@ -257,12 +265,14 @@ return $this ->as(UserResponse::class); ``` -Envelope classes must implement `ResponseEnvelope`: +Envelope classes must implement `ResponseEnvelopeInterface`: ```php use ProgrammatorDev\Api\Context; +use ProgrammatorDev\Api\Response; +use ProgrammatorDev\Api\ResponseEnvelopeInterface; -final class UserResponse implements ResponseEnvelope +final class UserResponse implements ResponseEnvelopeInterface { public function __construct( private readonly User $user, diff --git a/docs/responses.md b/docs/responses.md index 269b301..70bcc0e 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -30,7 +30,7 @@ Returns the raw PSR response. $status = $response->raw()->getStatusCode(); ``` -### `entity(string $class, ?string $key = null): Entity` +### `entity(string $class, ?string $key = null): EntityInterface` Maps decoded response data to an entity class. @@ -40,7 +40,7 @@ return $this ->entity(User::class, key: 'data'); ``` -The class must implement `Entity`. +The class must implement `EntityInterface`. ### `collection(string $class, ?string $key = null): array` @@ -52,7 +52,7 @@ return $this ->collection(User::class, key: 'data'); ``` -### `as(string $class): ResponseEnvelope` +### `as(string $class): ResponseEnvelopeInterface` Maps the response to a custom envelope. @@ -62,9 +62,9 @@ return $this ->as(UserResponse::class); ``` -The class must implement `ResponseEnvelope`. +The class must implement `ResponseEnvelopeInterface`. -## `Entity` +## `EntityInterface` Entities used by response mapping must implement: @@ -72,7 +72,7 @@ Entities used by response mapping must implement: public static function fromArray(array $data, ?Context $context = null): static; ``` -## `ResponseEnvelope` +## `ResponseEnvelopeInterface` Response envelopes used by `Response::as()` must implement: @@ -87,8 +87,8 @@ public static function fromResponse(Response $response, ?Context $context = null SDK users do not fetch context from `Response`. The package passes context into entity and envelope hydration methods: ```php -Entity::fromArray(array $data, ?Context $context = null) -ResponseEnvelope::fromResponse(Response $response, ?Context $context = null) +EntityInterface::fromArray(array $data, ?Context $context = null) +ResponseEnvelopeInterface::fromResponse(Response $response, ?Context $context = null) ``` ### `config(): Config` diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index a141774..0725bcf 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -139,13 +139,13 @@ Responsibilities: Custom envelopes should implement: ```php -interface ResponseEnvelope +interface ResponseEnvelopeInterface { public static function fromResponse(Response $response, ?Context $context = null): static; } ``` -`Response::as()` should require `ResponseEnvelope`. +`Response::as()` should require `ResponseEnvelopeInterface`. The wrapper should be named `Response`, not `ApiResponse`. PSR responses are referenced through `ResponseInterface`, so the shorter package name is acceptable and keeps SDK authoring readable. @@ -163,7 +163,7 @@ public function findWithMeta(int $id): UserResponse } ``` -### `Entity` +### Entity Interface Required contract for typed response objects used by `Response::entity()` and `Response::collection()`. @@ -181,7 +181,7 @@ Non-goals: Proposed contract: ```php -interface Entity +interface EntityInterface { public static function fromArray(array $data, ?Context $context = null): static; } @@ -335,9 +335,9 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - v3 should remove old v2 public low-level methods instead of keeping deprecated aliases. - SDK-wide options should be stored in a generic config bag. - SDK config should be available through context objects, not by injecting `Api` into entities. -- `Response::entity()` and `Response::collection()` should require classes that implement `Entity`. +- `Response::entity()` and `Response::collection()` should require classes that implement `EntityInterface`. - `Response::as()` should support API-specific response envelope classes such as item, collection, metadata, and pagination responses. -- `Response::as()` should require a `ResponseEnvelope` contract with `fromResponse(Response $response, ?Context $context = null)`. +- `Response::as()` should require a `ResponseEnvelopeInterface` contract with `fromResponse(Response $response, ?Context $context = null)`. - `Response::collection()` should return a plain array by default. - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. - Symfony EventDispatcher has been replaced with a smaller request/response pipeline. @@ -382,7 +382,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - `body(string|StreamInterface)` should not guess `Content-Type`. - `responses()->json()` should decode all responses, including error responses. - v3 should not throw for HTTP error status codes by default. SDK authors opt into error behavior through `errors()`. -- Main author-facing classes should stay in the root namespace: `Api`, `Resource`, `Response`, `Entity`, and `ResponseEnvelope`. +- Main author-facing classes should stay in the root namespace: `Api`, `Resource`, `Response`, `EntityInterface`, and `ResponseEnvelopeInterface`. - Internal/supporting classes can live in subnamespaces such as `Request`, `Context`, and `Builder`. - Package exception classes can be decided as implementation needs emerge. - Tests should use generic fake SDK fixtures, not downstream SDK names or classes. @@ -477,7 +477,7 @@ Future-phase questions should be answered when that phase starts, not before: ## First Implementation Slice 1. Add fake SDK fixtures under tests. -2. Add `Resource`, `RequestOptions`, `Response`, and `Entity`. +2. Add `Resource`, `RequestOptions`, `Response`, and `EntityInterface`. 3. Add protected/fluent resource creation and request execution to `Api`. 4. Prove one simple endpoint flow with a mock PSR client: diff --git a/src/Api.php b/src/Api.php index deea485..28d1834 100644 --- a/src/Api.php +++ b/src/Api.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api; -use JsonException; use ProgrammatorDev\Api\Builder\AuthBuilder; use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; @@ -17,8 +16,6 @@ use Psr\Http\Client\ClientInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; -use RuntimeException; -use Throwable; class Api { @@ -59,9 +56,9 @@ public function __construct() /** * @throws ClientExceptionInterface - * @throws JsonException - * @throws RuntimeException - * @throws Throwable + * @throws \JsonException + * @throws \RuntimeException + * @throws \Throwable */ public function send( string $method, diff --git a/src/Entity.php b/src/EntityInterface.php similarity index 83% rename from src/Entity.php rename to src/EntityInterface.php index 4de4661..5b3a1ad 100644 --- a/src/Entity.php +++ b/src/EntityInterface.php @@ -2,7 +2,7 @@ namespace ProgrammatorDev\Api; -interface Entity +interface EntityInterface { public static function fromArray(array $data, ?Context $context = null): static; } diff --git a/src/Response.php b/src/Response.php index 9850c85..39a589d 100644 --- a/src/Response.php +++ b/src/Response.php @@ -27,11 +27,11 @@ public function raw(): ResponseInterface } /** - * @template T of Entity + * @template T of EntityInterface * @param class-string $class * @return T */ - public function entity(string $class, ?string $key = null): Entity + public function entity(string $class, ?string $key = null): EntityInterface { $this->assertEntityClass($class); @@ -45,7 +45,7 @@ public function entity(string $class, ?string $key = null): Entity } /** - * @template T of Entity + * @template T of EntityInterface * @param class-string $class * @return T[] */ @@ -61,7 +61,7 @@ public function collection(string $class, ?string $key = null): array $context = $this->context; - return array_map(static function (mixed $item) use ($class, $context): Entity { + return array_map(static function (mixed $item) use ($class, $context): EntityInterface { if (!is_array($item)) { throw new \UnexpectedValueException('Collection item data must be an array.'); } @@ -71,11 +71,11 @@ public function collection(string $class, ?string $key = null): array } /** - * @template T of ResponseEnvelope + * @template T of ResponseEnvelopeInterface * @param class-string $class * @return T */ - public function as(string $class): ResponseEnvelope + public function as(string $class): ResponseEnvelopeInterface { $this->assertResponseEnvelopeClass($class); @@ -103,11 +103,11 @@ private function getData(?string $key): mixed */ private function assertEntityClass(string $class): void { - if (!is_subclass_of($class, Entity::class)) { + if (!is_subclass_of($class, EntityInterface::class)) { throw new \InvalidArgumentException(sprintf( 'Entity class "%s" must implement %s.', $class, - Entity::class + EntityInterface::class )); } } @@ -117,11 +117,11 @@ private function assertEntityClass(string $class): void */ private function assertResponseEnvelopeClass(string $class): void { - if (!is_subclass_of($class, ResponseEnvelope::class)) { + if (!is_subclass_of($class, ResponseEnvelopeInterface::class)) { throw new \InvalidArgumentException(sprintf( 'Response envelope class "%s" must implement %s.', $class, - ResponseEnvelope::class + ResponseEnvelopeInterface::class )); } } diff --git a/src/ResponseEnvelope.php b/src/ResponseEnvelopeInterface.php similarity index 79% rename from src/ResponseEnvelope.php rename to src/ResponseEnvelopeInterface.php index 3619ca9..e036cf4 100644 --- a/src/ResponseEnvelope.php +++ b/src/ResponseEnvelopeInterface.php @@ -2,7 +2,7 @@ namespace ProgrammatorDev\Api; -interface ResponseEnvelope +interface ResponseEnvelopeInterface { public static function fromResponse(Response $response, ?Context $context = null): static; } diff --git a/tests/Fixture/User.php b/tests/Fixture/User.php index 1e427b6..e4064bc 100644 --- a/tests/Fixture/User.php +++ b/tests/Fixture/User.php @@ -3,9 +3,9 @@ namespace ProgrammatorDev\Api\Test\Fixture; use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\Entity; +use ProgrammatorDev\Api\EntityInterface; -class User implements Entity +class User implements EntityInterface { public function __construct( private readonly int $id, diff --git a/tests/Fixture/UserEnvelope.php b/tests/Fixture/UserEnvelope.php index 9448b9f..a7412e9 100644 --- a/tests/Fixture/UserEnvelope.php +++ b/tests/Fixture/UserEnvelope.php @@ -4,9 +4,9 @@ use ProgrammatorDev\Api\Context; use ProgrammatorDev\Api\Response; -use ProgrammatorDev\Api\ResponseEnvelope; +use ProgrammatorDev\Api\ResponseEnvelopeInterface; -class UserEnvelope implements ResponseEnvelope +class UserEnvelope implements ResponseEnvelopeInterface { public function __construct( private readonly User $user, From dc8b415fd8ca71687d98ce1b848de41b6d59af4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 12:13:41 +0100 Subject: [PATCH 35/88] refactor(v3): organize core namespaces --- docs/getting-started.md | 10 +++++----- docs/resource-authoring.md | 20 +++++++++---------- src/Api.php | 5 +++++ src/Builder/CacheBuilder.php | 2 +- src/Builder/ResponseBuilder.php | 2 +- src/{ => Config}/Config.php | 2 +- src/{ => Context}/Context.php | 4 +++- src/Context/ErrorContext.php | 3 +-- src/Context/RequestContext.php | 1 - src/Context/ResponseContext.php | 1 - src/{ => Contract}/EntityInterface.php | 4 +++- .../ResponseEnvelopeInterface.php | 5 ++++- src/{ => Http}/Method.php | 4 ++-- src/{ => Http}/Transport.php | 3 ++- src/Resource.php | 3 +++ src/{ => Response}/Response.php | 5 ++++- src/{ => Response}/ResponseDecoder.php | 2 +- src/{ => Response}/ResponseFormat.php | 2 +- tests/Fixture/RawResource.php | 2 +- tests/Fixture/User.php | 4 ++-- tests/Fixture/UserEnvelope.php | 6 +++--- tests/Integration/ApiTest.php | 2 +- tests/Unit/Builder/ErrorBuilderTest.php | 6 +++--- tests/Unit/ConfigTest.php | 2 +- tests/Unit/Context/ErrorContextTest.php | 6 +++--- tests/Unit/ContextTest.php | 4 ++-- tests/Unit/ResponseDecoderTest.php | 2 +- tests/Unit/ResponseTest.php | 6 +++--- 28 files changed, 67 insertions(+), 51 deletions(-) rename src/{ => Config}/Config.php (95%) rename src/{ => Context}/Context.php (71%) rename src/{ => Contract}/EntityInterface.php (60%) rename src/{ => Contract}/ResponseEnvelopeInterface.php (53%) rename src/{ => Http}/Method.php (89%) rename src/{ => Http}/Transport.php (98%) rename src/{ => Response}/Response.php (94%) rename src/{ => Response}/ResponseDecoder.php (98%) rename src/{ => Response}/ResponseFormat.php (69%) diff --git a/docs/getting-started.md b/docs/getting-started.md index 228b4c8..505e579 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -49,8 +49,8 @@ $user = $api->users()->find(1); Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `EntityInterface`. ```php -use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\EntityInterface; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; final class User implements EntityInterface { @@ -131,9 +131,9 @@ $activeUsers = $api If an API returns metadata, pagination, or any custom envelope, create a response envelope class. ```php -use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\Response; -use ProgrammatorDev\Api\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; final class UserResponse implements ResponseEnvelopeInterface { diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index e73ca0a..04f97f0 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -140,8 +140,8 @@ return $this Entities must implement `EntityInterface`: ```php -use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\EntityInterface; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; final class User implements EntityInterface { @@ -209,8 +209,8 @@ When a response is mapped, the API creates a context with that config. The same Entities can use config values during hydration: ```php -use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\EntityInterface; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; final class User implements EntityInterface { @@ -234,9 +234,9 @@ final class User implements EntityInterface Response envelopes receive the same context: ```php -use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\Response; -use ProgrammatorDev\Api\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; final class UserResponse implements ResponseEnvelopeInterface { @@ -268,9 +268,9 @@ return $this Envelope classes must implement `ResponseEnvelopeInterface`: ```php -use ProgrammatorDev\Api\Context; -use ProgrammatorDev\Api\Response; -use ProgrammatorDev\Api\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; final class UserResponse implements ResponseEnvelopeInterface { diff --git a/src/Api.php b/src/Api.php index 28d1834..c531ab3 100644 --- a/src/Api.php +++ b/src/Api.php @@ -10,8 +10,13 @@ use ProgrammatorDev\Api\Builder\LoggerBuilder; use ProgrammatorDev\Api\Builder\PluginBuilder; use ProgrammatorDev\Api\Builder\ResponseBuilder; +use ProgrammatorDev\Api\Config\Config; +use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Context\ErrorContext; +use ProgrammatorDev\Api\Http\Transport; use ProgrammatorDev\Api\Request\RequestOptions; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Response\ResponseDecoder; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Cache\CacheItemPoolInterface; diff --git a/src/Builder/CacheBuilder.php b/src/Builder/CacheBuilder.php index f49a5b9..ecd4d3e 100644 --- a/src/Builder/CacheBuilder.php +++ b/src/Builder/CacheBuilder.php @@ -2,7 +2,7 @@ namespace ProgrammatorDev\Api\Builder; -use ProgrammatorDev\Api\Method; +use ProgrammatorDev\Api\Http\Method; use Psr\Cache\CacheItemPoolInterface; class CacheBuilder diff --git a/src/Builder/ResponseBuilder.php b/src/Builder/ResponseBuilder.php index d3e58f8..2278450 100644 --- a/src/Builder/ResponseBuilder.php +++ b/src/Builder/ResponseBuilder.php @@ -2,7 +2,7 @@ namespace ProgrammatorDev\Api\Builder; -use ProgrammatorDev\Api\ResponseFormat; +use ProgrammatorDev\Api\Response\ResponseFormat; use Psr\Http\Message\ResponseInterface; class ResponseBuilder diff --git a/src/Config.php b/src/Config/Config.php similarity index 95% rename from src/Config.php rename to src/Config/Config.php index 2a22b45..d830bf2 100644 --- a/src/Config.php +++ b/src/Config/Config.php @@ -1,6 +1,6 @@ Date: Sat, 6 Jun 2026 12:19:07 +0100 Subject: [PATCH 36/88] refactor(v3): rename default request helpers --- docs/api.md | 24 ++++++++++++++++++++---- docs/authentication.md | 4 ++-- docs/getting-started.md | 4 ++-- docs/http-client.md | 2 +- docs/resource-authoring.md | 2 +- docs/v3-architecture-plan.md | 4 ++-- src/Api.php | 18 ++++++++++++++++-- tests/Fixture/FakeApi.php | 16 +++++++++++++++- tests/Integration/ApiTest.php | 24 ++++++++++++++++++++++++ 9 files changed, 83 insertions(+), 15 deletions(-) diff --git a/docs/api.md b/docs/api.md index 51eb13a..9faf4d2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,12 +59,20 @@ $this->baseUrl('https://api.example.com'); Full request URLs passed to resources override the configured base URL. -## `queryDefaults(array $query): static` +## `defaultQuery(string $name, mixed $value): static` + +Protected fluent helper for configuring one query parameter applied to every request. + +```php +$this->defaultQuery('api_key', $apiKey); +``` + +## `defaultQueries(array $query): static` Protected fluent helper for configuring query parameters applied to every request. ```php -$this->queryDefaults(['api_key' => $apiKey, 'locale' => 'en']); +$this->defaultQueries(['api_key' => $apiKey, 'locale' => 'en']); ``` Query merge order is: @@ -73,12 +81,20 @@ Query merge order is: API defaults < resource options < endpoint-specific options ``` -## `headerDefaults(array $headers): static` +## `defaultHeader(string $name, mixed $value): static` + +Protected fluent helper for configuring one header applied to every request. + +```php +$this->defaultHeader('Accept', 'application/json'); +``` + +## `defaultHeaders(array $headers): static` Protected fluent helper for configuring headers applied to every request. ```php -$this->headerDefaults(['Accept' => 'application/json']); +$this->defaultHeaders(['Accept' => 'application/json']); ``` Header names are not normalized by the package. diff --git a/docs/authentication.md b/docs/authentication.md index 8556949..29b7985 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -47,10 +47,10 @@ Use `query()` only when the API requires credentials in the URL. $this->auth()->query('api_key', $apiKey); ``` -For non-sensitive default query parameters such as `locale`, `units`, or `timezone`, use `queryDefaults()` instead: +For non-sensitive default query parameters such as `locale`, `units`, or `timezone`, use `defaultQueries()` instead: ```php -$this->queryDefaults(['units' => 'metric']); +$this->defaultQueries(['units' => 'metric']); ``` ## HTTPlug Authentication Objects diff --git a/docs/getting-started.md b/docs/getting-started.md index 505e579..414b95f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -25,8 +25,8 @@ final class ExampleApi extends Api $this ->baseUrl('https://api.example.com') - ->queryDefaults(['locale' => 'en']) - ->headerDefaults(['Accept' => 'application/json']); + ->defaultQueries(['locale' => 'en']) + ->defaultHeaders(['Accept' => 'application/json']); $this->auth()->query('api_key', $apiKey); } diff --git a/docs/http-client.md b/docs/http-client.md index 94c9922..bd030a2 100644 --- a/docs/http-client.md +++ b/docs/http-client.md @@ -34,7 +34,7 @@ final class ExampleApi extends Api ) { $this ->baseUrl('https://api.example.com') - ->queryDefaults(['api_key' => $apiKey]) + ->defaultQueries(['api_key' => $apiKey]) ->client($client) ->requestFactory($requestFactory) ->streamFactory($streamFactory) diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index 04f97f0..8a5d379 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -194,7 +194,7 @@ final class ExampleApi extends Api $this ->baseUrl('https://api.example.com') - ->queryDefaults(['api_key' => $apiKey]); + ->defaultQueries(['api_key' => $apiKey]); $this->config($options); } diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 0725bcf..ea4d1d0 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -315,7 +315,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon | `Api::buildPath` | Path parameter replacement inside `Resource`/transport `get('/x/{id}', ['id' => $id])` | | `setBaseUrl` / `getBaseUrl` | Fluent `baseUrl(...)`, optional getter only if useful | | SDK-specific global options | Generic config bag exposed to resources/responses/entities through context | -| Query/header defaults | Fluent `queryDefaults(...)`, `headerDefaults(...)` | +| Query/header defaults | Fluent `defaultQueries(...)`, `defaultHeaders(...)` | | Per-resource query options | New `RequestOptions`, exposed through `Resource::query(...)` and SDK-specific traits | | `setAuthentication` | Fluent `auth()` helper wrapping HTTPlug authentication plus low-level authentication injection | | Client/factory injection | Keep builder-style or fluent config methods | @@ -403,7 +403,7 @@ final class ExampleApi extends Api ->baseUrl('https://api.example.com') ->auth()->bearer($token) ->config(['timezone' => 'UTC']) - ->queryDefaults(['locale' => 'en']) + ->defaultQueries(['locale' => 'en']) ->responses()->json(); } diff --git a/src/Api.php b/src/Api.php index c531ab3..e9e8ece 100644 --- a/src/Api.php +++ b/src/Api.php @@ -111,14 +111,28 @@ protected function baseUrl(?string $baseUrl): static return $this; } - protected function queryDefaults(array $query): static + protected function defaultQuery(string $name, mixed $value): static + { + $this->queryDefaults[$name] = $value; + + return $this; + } + + protected function defaultQueries(array $query): static { $this->queryDefaults = array_merge($this->queryDefaults, $query); return $this; } - protected function headerDefaults(array $headers): static + protected function defaultHeader(string $name, mixed $value): static + { + $this->headerDefaults[$name] = $value; + + return $this; + } + + protected function defaultHeaders(array $headers): static { $this->headerDefaults = array_merge($this->headerDefaults, $headers); diff --git a/tests/Fixture/FakeApi.php b/tests/Fixture/FakeApi.php index eceee5e..c14e33c 100644 --- a/tests/Fixture/FakeApi.php +++ b/tests/Fixture/FakeApi.php @@ -16,7 +16,7 @@ public function __construct(Client $client) $this ->baseUrl('https://api.example.com') - ->queryDefaults(['locale' => 'en']) + ->defaultQueries(['locale' => 'en']) ->responses() ->json(); } @@ -25,4 +25,18 @@ public function users(): UserResource { return $this->resource(UserResource::class); } + + public function withDefaultQuery(string $name, mixed $value): self + { + $this->defaultQuery($name, $value); + + return $this; + } + + public function withDefaultHeader(string $name, mixed $value): self + { + $this->defaultHeader($name, $value); + + return $this; + } } diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index c7d991d..43ba9ab 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -38,4 +38,28 @@ public function testApiCanSendPublicRequest(): void $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); } + + public function testApiCanSendRequestWithDefaultQuery(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + (new FakeApi($client)) + ->withDefaultQuery('units', 'metric') + ->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $client->getLastRequest()->getUri()); + } + + public function testApiCanSendRequestWithDefaultHeader(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + (new FakeApi($client)) + ->withDefaultHeader('Accept', 'application/json') + ->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame('application/json', $client->getLastRequest()->getHeaderLine('Accept')); + } } From ae2c17d18e687a204b73a3afdda79260b7cef78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 12:22:52 +0100 Subject: [PATCH 37/88] refactor(v3): make api abstract --- docs/v3-architecture-plan.md | 17 +++++++++-------- src/Api.php | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index ea4d1d0..e7efd70 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -471,6 +471,7 @@ Future-phase questions should be answered when that phase starts, not before: - Exact hook method names and context details. - Whether any public configuration getters are useful for testing or advanced extension. - Whether `Method` remains as a tiny compatibility helper or is removed entirely. +- How endpoint-local cache options should work without muddying request options or cloning full API builders. - Whether `config()` ever supports nested keys. - Whether a future `collect()` helper should return a small generic collection object. @@ -511,25 +512,25 @@ Before tagging v3: - [x] PSR-18 client support. - [x] PSR-17 request factory support. - [x] PSR-17 stream factory support. -- [ ] PSR-6 cache support. -- [ ] PSR-3 logger support. +- [x] PSR-6 cache support. +- [x] PSR-3 logger support. - [x] Authentication support. - [x] Plugin support. -- [ ] Request hooks. -- [ ] Response hooks. -- [ ] Response content transformation. +- [x] Request hooks. +- [x] Response hooks. +- [x] Response content transformation. - [x] Query defaults. - [x] Header defaults. - [x] Base URL handling. - [x] Path parameter replacement. - [x] Resource HTTP verb helpers. - [x] Resource body helpers. -- [ ] JSON response decoding. -- [ ] Error mapping. +- [x] JSON response decoding. +- [x] Error mapping. - [x] Entity mapping. - [x] Collection mapping. - [x] Custom response envelope mapping. -- [ ] Entity context and SDK config access. +- [x] Entity context and SDK config access. - [x] SDK author test fixtures. - [ ] README update. - [ ] `UPGRADE-3.0.md`. diff --git a/src/Api.php b/src/Api.php index e9e8ece..e1b6d63 100644 --- a/src/Api.php +++ b/src/Api.php @@ -22,7 +22,7 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; -class Api +abstract class Api { private ?string $baseUrl = null; From ff32d3d03a86546f1d18706e381430795a710f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 12:41:45 +0100 Subject: [PATCH 38/88] refactor(v3): standardize builder getters --- src/Builder/AuthBuilder.php | 2 +- src/Builder/PluginBuilder.php | 6 +++--- src/Builder/ResponseBuilder.php | 4 ++-- src/Http/Transport.php | 4 ++-- src/Response/ResponseDecoder.php | 4 ++-- tests/Unit/Builder/AuthBuilderTest.php | 16 ++++++++-------- tests/Unit/Builder/PluginBuilderTest.php | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Builder/AuthBuilder.php b/src/Builder/AuthBuilder.php index 4d39ce9..ab8dbb3 100644 --- a/src/Builder/AuthBuilder.php +++ b/src/Builder/AuthBuilder.php @@ -68,7 +68,7 @@ public function custom(callable $callback): self return $this; } - public function authentication(): ?Authentication + public function getAuthentication(): ?Authentication { if ($this->authentications === []) { return null; diff --git a/src/Builder/PluginBuilder.php b/src/Builder/PluginBuilder.php index b496771..27946f9 100644 --- a/src/Builder/PluginBuilder.php +++ b/src/Builder/PluginBuilder.php @@ -19,7 +19,7 @@ public function add(Plugin $plugin, int $priority = 0): self public function merge(self $builder): self { - foreach ($builder->entries() as $priority => $plugins) { + foreach ($builder->getEntries() as $priority => $plugins) { foreach ($plugins as $plugin) { $this->add($plugin, $priority); } @@ -31,7 +31,7 @@ public function merge(self $builder): self /** * @return array> */ - public function entries(): array + public function getEntries(): array { return $this->plugins; } @@ -39,7 +39,7 @@ public function entries(): array /** * @return list */ - public function all(): array + public function getPlugins(): array { if ($this->plugins === []) { return []; diff --git a/src/Builder/ResponseBuilder.php b/src/Builder/ResponseBuilder.php index 2278450..66cb146 100644 --- a/src/Builder/ResponseBuilder.php +++ b/src/Builder/ResponseBuilder.php @@ -47,7 +47,7 @@ public function custom(callable $decoder): self return $this; } - public function format(): ResponseFormat + public function getFormat(): ResponseFormat { return $this->format; } @@ -55,7 +55,7 @@ public function format(): ResponseFormat /** * @return null|callable(ResponseInterface): mixed */ - public function customDecoder(): ?callable + public function getCustomDecoder(): ?callable { return $this->customDecoder; } diff --git a/src/Http/Transport.php b/src/Http/Transport.php index 6fe3c0e..fd1b109 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -105,7 +105,7 @@ private function buildPlugins(): array priority: self::CONTENT_LENGTH_PLUGIN_PRIORITY ); - if ($authentication = $this->authBuilder->authentication()) { + if ($authentication = $this->authBuilder->getAuthentication()) { $plugins->add( plugin: new AuthenticationPlugin($authentication), priority: self::AUTHENTICATION_PLUGIN_PRIORITY @@ -128,7 +128,7 @@ private function buildPlugins(): array $plugins->merge($this->pluginBuilder); - return $plugins->all(); + return $plugins->getPlugins(); } private function buildCachePlugin(): ?Plugin diff --git a/src/Response/ResponseDecoder.php b/src/Response/ResponseDecoder.php index 1c6b904..1c8a4e4 100644 --- a/src/Response/ResponseDecoder.php +++ b/src/Response/ResponseDecoder.php @@ -22,7 +22,7 @@ public function decode(ResponseInterface $response): mixed $response->getBody()->rewind(); $contents = $response->getBody()->getContents(); - return match ($this->responseBuilder->format()) { + return match ($this->responseBuilder->getFormat()) { ResponseFormat::Raw => $contents, ResponseFormat::Json => $this->decodeJson($contents), ResponseFormat::Xml => $this->decodeXml($contents), @@ -66,7 +66,7 @@ private function decodeXml(string $contents): ?SimpleXMLElement private function decodeCustom(ResponseInterface $response): mixed { - $decoder = $this->responseBuilder->customDecoder(); + $decoder = $this->responseBuilder->getCustomDecoder(); if ($decoder === null) { throw new RuntimeException('A custom response decoder must be configured.'); diff --git a/tests/Unit/Builder/AuthBuilderTest.php b/tests/Unit/Builder/AuthBuilderTest.php index 183d805..166867a 100644 --- a/tests/Unit/Builder/AuthBuilderTest.php +++ b/tests/Unit/Builder/AuthBuilderTest.php @@ -13,14 +13,14 @@ class AuthBuilderTest extends AbstractTestCase { public function testAuthenticationIsNullWhenNoAuthWasConfigured(): void { - $this->assertNull((new AuthBuilder())->authentication()); + $this->assertNull((new AuthBuilder())->getAuthentication()); } public function testSingleAuthenticationIsReturnedDirectly(): void { $authentication = (new AuthBuilder()) ->bearer('token') - ->authentication(); + ->getAuthentication(); $request = $authentication->authenticate(new Request('GET', 'https://api.example.com')); @@ -32,7 +32,7 @@ public function testMultipleAuthenticationsAreReturnedAsChain(): void $authentication = (new AuthBuilder()) ->bearer('token') ->query('appid', 'key') - ->authentication(); + ->getAuthentication(); $this->assertInstanceOf(Chain::class, $authentication); @@ -46,7 +46,7 @@ public function testExplicitChainAcceptsHttplugAuthenticationObjects(): void { $authentication = (new AuthBuilder()) ->chain(new Header('X-Api-Key', 'secret')) - ->authentication(); + ->getAuthentication(); $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); @@ -57,7 +57,7 @@ public function testWsseAuthenticationAddsWsseHeaders(): void { $authentication = (new AuthBuilder()) ->wsse('user', 'pass') - ->authentication(); + ->getAuthentication(); $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); @@ -69,7 +69,7 @@ public function testConditionalAuthenticationOnlyAppliesWhenMatched(): void { $authentication = (new AuthBuilder()) ->conditional(new RequestMatcher(path: '^/admin'), new Header('X-Admin-Auth', 'secret')) - ->authentication(); + ->getAuthentication(); $matched = $authentication->authenticate(new Request('GET', 'https://api.example.com/admin/users')); $unmatched = $authentication->authenticate(new Request('GET', 'https://api.example.com/users')); @@ -82,7 +82,7 @@ public function testCustomAuthenticationUsesCallback(): void { $authentication = (new AuthBuilder()) ->custom(fn(Request $request) => $request->withHeader('X-Custom-Auth', 'custom')) - ->authentication(); + ->getAuthentication(); $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); @@ -93,7 +93,7 @@ public function testCustomAuthenticationCallbackMustReturnRequest(): void { $authentication = (new AuthBuilder()) ->custom(fn() => null) - ->authentication(); + ->getAuthentication(); $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Custom authentication callback must return a PSR-7 request.'); diff --git a/tests/Unit/Builder/PluginBuilderTest.php b/tests/Unit/Builder/PluginBuilderTest.php index 59778fa..2905401 100644 --- a/tests/Unit/Builder/PluginBuilderTest.php +++ b/tests/Unit/Builder/PluginBuilderTest.php @@ -18,7 +18,7 @@ public function testPluginsAreReturnedByDescendingPriority(): void ->add($low, priority: 8) ->add($high, priority: 40) ->add($middle, priority: 16) - ->all(); + ->getPlugins(); $this->assertSame([$high, $middle, $low], $plugins); } @@ -31,7 +31,7 @@ public function testPluginsWithSamePriorityArePreservedInInsertionOrder(): void $plugins = (new PluginBuilder()) ->add($first, priority: 16) ->add($second, priority: 16) - ->all(); + ->getPlugins(); $this->assertSame([$first, $second], $plugins); } @@ -46,7 +46,7 @@ public function testPluginBuildersCanBeMergedWithoutLosingPriority(): void $plugins = (new PluginBuilder()) ->add($low, priority: 8) ->merge($source) - ->all(); + ->getPlugins(); $this->assertSame([$high, $low], $plugins); } From 097ef0cd439bb3d6fb6cb6b83e9c86b6e635fc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 12:44:30 +0100 Subject: [PATCH 39/88] refactor(v3): align default request naming --- src/Api.php | 16 ++++++++-------- src/Http/Transport.php | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Api.php b/src/Api.php index e1b6d63..88eaffe 100644 --- a/src/Api.php +++ b/src/Api.php @@ -26,9 +26,9 @@ abstract class Api { private ?string $baseUrl = null; - private array $queryDefaults = []; + private array $defaultQueries = []; - private array $headerDefaults = []; + private array $defaultHeaders = []; private Config $config; @@ -113,28 +113,28 @@ protected function baseUrl(?string $baseUrl): static protected function defaultQuery(string $name, mixed $value): static { - $this->queryDefaults[$name] = $value; + $this->defaultQueries[$name] = $value; return $this; } protected function defaultQueries(array $query): static { - $this->queryDefaults = array_merge($this->queryDefaults, $query); + $this->defaultQueries = array_merge($this->defaultQueries, $query); return $this; } protected function defaultHeader(string $name, mixed $value): static { - $this->headerDefaults[$name] = $value; + $this->defaultHeaders[$name] = $value; return $this; } protected function defaultHeaders(array $headers): static { - $this->headerDefaults = array_merge($this->headerDefaults, $headers); + $this->defaultHeaders = array_merge($this->defaultHeaders, $headers); return $this; } @@ -204,8 +204,8 @@ private function transport(): Transport cacheBuilder: $this->cacheBuilder, loggerBuilder: $this->loggerBuilder, baseUrl: $this->baseUrl, - queryDefaults: $this->queryDefaults, - headerDefaults: $this->headerDefaults + defaultQueries: $this->defaultQueries, + defaultHeaders: $this->defaultHeaders ); } diff --git a/src/Http/Transport.php b/src/Http/Transport.php index fd1b109..e4e22ab 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -41,8 +41,8 @@ public function __construct( private readonly ?CacheBuilder $cacheBuilder = null, private readonly ?LoggerBuilder $loggerBuilder = null, private readonly ?string $baseUrl = null, - private readonly array $queryDefaults = [], - private readonly array $headerDefaults = [] + private readonly array $defaultQueries = [], + private readonly array $defaultHeaders = [] ) {} /** @@ -63,12 +63,12 @@ public function send( $query = $options->getQuery(); $headers = $options->getHeaders(); - if (!empty($this->queryDefaults)) { - $query = array_merge($this->queryDefaults, $query); + if (!empty($this->defaultQueries)) { + $query = array_merge($this->defaultQueries, $query); } - if (!empty($this->headerDefaults)) { - $headers = array_merge($this->headerDefaults, $headers); + if (!empty($this->defaultHeaders)) { + $headers = array_merge($this->defaultHeaders, $headers); } $request = $this->createRequest( From 2abce876da267e27081c504e12af422403e3a429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 13:10:23 +0100 Subject: [PATCH 40/88] feat(v3): add setup facade --- docs/api.md | 40 ++++++-- docs/authentication.md | 12 +-- docs/cache.md | 26 +++--- docs/http-client.md | 10 +- docs/logging.md | 15 ++- docs/plugins.md | 2 +- docs/v3-architecture-plan.md | 2 +- src/Api.php | 17 +++- src/ApiSetup.php | 111 +++++++++++++++++++++++ src/Builder/AuthBuilder.php | 38 +++----- tests/Integration/ApiTest.php | 23 +++++ tests/Integration/AuthenticationTest.php | 16 ++++ tests/Integration/CacheTest.php | 2 +- tests/Integration/PluginTest.php | 2 +- tests/Unit/Builder/AuthBuilderTest.php | 27 +++++- 15 files changed, 260 insertions(+), 83 deletions(-) create mode 100644 src/ApiSetup.php diff --git a/docs/api.md b/docs/api.md index 9faf4d2..41af3e0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -35,6 +35,18 @@ $api->config(['timezone' => 'UTC']); $api->config()->get('timezone'); ``` +## `setup(): ApiSetup` + +Public access to SDK setup and extension points without adding every setup method to the concrete SDK surface. + +```php +$api->setup()->plugins()->add($plugin); +$api->setup()->client($client); +$api->setup()->auth()->bearer($token); +``` + +SDK authors can call the same setup methods directly from subclasses. + ## `resource(string $class): Resource` Protected helper for creating resource instances from an API class. @@ -115,11 +127,13 @@ See [Authentication](authentication.md) for helper methods, HTTPlug authenticati ## `hooks(): HookBuilder` -Public access to request and response hooks. +Protected access to request and response hooks. SDK users can access hooks through `setup()`. ```php $this->hooks()->beforeRequest($hook); $this->hooks()->afterResponse($hook); + +$api->setup()->hooks()->beforeRequest($hook); ``` Hooks are SDK-author extension points. They run around the raw HTTP request and response, before response decoding and error handling. @@ -128,10 +142,12 @@ See [Hooks](hooks.md) for hook context objects, return values, and priority beha ## `plugins(): PluginBuilder` -Public access to HTTPlug plugin configuration. +Protected access to HTTPlug plugin configuration. SDK users can access plugins through `setup()`. ```php -$api->plugins()->add($plugin, priority: 16); +$this->plugins()->add($plugin, priority: 16); + +$api->setup()->plugins()->add($plugin, priority: 16); ``` Higher priority plugins run earlier. Same-priority plugins are preserved in insertion order. @@ -140,23 +156,27 @@ See [Plugins](plugins.md) for internal plugin order and priority guidance. ## `cache(CacheItemPoolInterface $pool): CacheBuilder` -Public access to PSR-6 HTTP response cache configuration. +Protected access to PSR-6 HTTP response cache configuration. SDK users can access cache through `setup()`. ```php -$api +$this ->cache($pool) ->defaultTtl(3600) ->methods(['GET', 'HEAD']); + +$api->setup()->cache($pool)->defaultTtl(3600); ``` See [Cache](cache.md) for cache options and plugin order. ## `client(ClientInterface $client): ClientBuilder` -Public access to PSR-18 client configuration. +Protected access to PSR-18 client configuration. SDK users can access client configuration through `setup()`. ```php -$api->client($client); +$this->client($client); + +$api->setup()->client($client); ``` SDK authors can configure PSR-17 factories on the returned builder: @@ -172,12 +192,14 @@ See [HTTP Client](http-client.md) for client and factory configuration. ## `logger(LoggerInterface $logger): LoggerBuilder` -Public access to PSR-3 logger configuration. +Protected access to PSR-3 logger configuration. SDK users can access logging through `setup()`. ```php -$api +$this ->logger($logger) ->formatter($formatter); + +$api->setup()->logger($logger); ``` See [Logging](logging.md) for logger formatting and cache logging. diff --git a/docs/authentication.md b/docs/authentication.md index 29b7985..1dd91f1 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -29,15 +29,7 @@ $this->auth()->wsse($username, $password); $this->auth()->wsse($username, $password, hashAlgorithm: 'sha512'); ``` -Multiple calls are chained in order: - -```php -$this->auth() - ->bearer($token) - ->query('appid', $apiKey); -``` - -Internally, multiple authentication rules become an [HTTPlug](https://httplug.io/) authentication chain. +Calling another helper replaces the previously configured authentication. Use `chain()` when an SDK needs multiple authentication rules. ## Query Authentication @@ -55,7 +47,7 @@ $this->defaultQueries(['units' => 'metric']); ## HTTPlug Authentication Objects -Use `chain()` when an SDK needs to reuse specific [HTTPlug authentication implementations](https://docs.php-http.org/en/latest/message/authentication.html): +Use `chain()` when an SDK needs to compose specific [HTTPlug authentication implementations](https://docs.php-http.org/en/latest/message/authentication.html): ```php use Http\Message\Authentication\Bearer; diff --git a/docs/cache.md b/docs/cache.md index 1c8f3fc..43f8ea8 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -2,17 +2,7 @@ Cache support uses the [PHP-HTTP cache plugin](https://docs.php-http.org/en/latest/plugins/cache.html) with a PSR-6 cache pool. -SDK users can configure cache on an API instance: - -```php -use Symfony\Component\Cache\Adapter\FilesystemAdapter; - -$api - ->cache(new FilesystemAdapter()) - ->defaultTtl(3600); -``` - -SDK authors can also configure cache from the `Api` class: +SDK authors can configure cache from the `Api` class: ```php $this @@ -21,22 +11,30 @@ $this ->methods(['GET', 'HEAD']); ``` +SDK users can also configure cache through `setup()`: + +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +$api->setup()->cache(new FilesystemAdapter())->defaultTtl(3600); +``` + ## Options ```php -$api->cache($pool)->defaultTtl(3600); +$this->cache($pool)->defaultTtl(3600); ``` Sets the fallback cache TTL in seconds when the response does not provide cache directives. Use `null` to let the cache backend store as long as it can. ```php -$api->cache($pool)->methods(['GET', 'HEAD']); +$this->cache($pool)->methods(['GET', 'HEAD']); ``` Sets which request methods can be cached. ```php -$api->cache($pool)->responseCacheDirectives(['max-age']); +$this->cache($pool)->responseCacheDirectives(['max-age']); ``` Sets the response cache directives respected by the cache plugin. diff --git a/docs/http-client.md b/docs/http-client.md index bd030a2..84c9e64 100644 --- a/docs/http-client.md +++ b/docs/http-client.md @@ -2,13 +2,13 @@ The SDK uses a PSR-18 HTTP client to send requests and PSR-17 factories to create requests and streams. -If compatible implementations are installed, the package can discover them automatically through PHP-HTTP discovery. SDK users can also provide concrete implementations explicitly. +If compatible implementations are installed, the package can discover them automatically through PHP-HTTP discovery. SDK authors can also provide concrete implementations explicitly. ```php use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; -$api +$this ->client(Psr18ClientDiscovery::find()) ->requestFactory(Psr17FactoryDiscovery::findRequestFactory()) ->streamFactory(Psr17FactoryDiscovery::findStreamFactory()); @@ -49,13 +49,14 @@ final class ExampleApi extends Api SDK users can replace the client on a concrete API instance. ```php -$api->client($client); +$api->setup()->client($client); ``` Factories can be replaced through the returned builder. ```php $api + ->setup() ->client($client) ->requestFactory($requestFactory) ->streamFactory($streamFactory); @@ -66,8 +67,7 @@ $api HTTPlug plugins are not configured on the client builder. They are configured through `plugins()` so global middleware has one predictable place to live. ```php -$api->plugins()->add($plugin, priority: 25); +$api->setup()->plugins()->add($plugin, priority: 25); ``` See [Plugins](plugins.md) for plugin order and priority guidance. - diff --git a/docs/logging.md b/docs/logging.md index c931e4d..745a6a9 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -2,16 +2,16 @@ Logging uses the [PHP-HTTP logger plugin](https://docs.php-http.org/en/latest/plugins/logger.html) with a PSR-3 logger. -SDK users can configure logging on an API instance: +SDK authors can configure logging from the `Api` class: ```php -$api->logger($logger); +$this->logger($logger); ``` -SDK authors can also configure logging from the `Api` class: +SDK users can also configure logging through `setup()`: ```php -$this->logger($logger); +$api->setup()->logger($logger); ``` ## Formatter @@ -19,7 +19,7 @@ $this->logger($logger); The logger plugin can receive a custom formatter. ```php -$api +$this ->logger($logger) ->formatter($formatter); ``` @@ -31,11 +31,11 @@ The formatter is passed directly to the HTTPlug logger plugin. When cache and logging are both configured, cache activity is also logged through the cache plugin listener. ```php -$api +$this ->cache($pool) ->defaultTtl(3600); -$api->logger($logger); +$this->logger($logger); ``` The cache listener logs: @@ -52,4 +52,3 @@ 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](plugins.md) for the full internal plugin order. - diff --git a/docs/plugins.md b/docs/plugins.md index 3b2f9b2..2ce84f9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -17,7 +17,7 @@ $this->plugins()->add($plugin, priority: 16); SDK users can also add plugins to a concrete API instance: ```php -$api->plugins()->add($retryPlugin, priority: 20); +$api->setup()->plugins()->add($retryPlugin, priority: 20); ``` Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index e7efd70..87d6623 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -107,7 +107,7 @@ Builder-backed features can follow the same shape when endpoint-specific behavio API-level builders configure global defaults: ```php -$api->cache($pool)->defaultTtl(3600); +$api->setup()->cache($pool)->defaultTtl(3600); ``` Request-local overrides should live on the pending request/resource flow instead of mutating the API-level builder: diff --git a/src/Api.php b/src/Api.php index 88eaffe..766d2fc 100644 --- a/src/Api.php +++ b/src/Api.php @@ -94,6 +94,13 @@ public function send( return $apiResponse; } + public function setup(): ApiSetup + { + return new ApiSetup( + fn(string $method, array $arguments): mixed => $this->{$method}(...$arguments) + ); + } + /** * @template T of Resource * @param class-string $class @@ -154,31 +161,31 @@ protected function auth(): AuthBuilder return $this->authBuilder; } - public function hooks(): HookBuilder + protected function hooks(): HookBuilder { return $this->hookBuilder; } - public function plugins(): PluginBuilder + protected function plugins(): PluginBuilder { return $this->pluginBuilder; } - public function cache(CacheItemPoolInterface $pool): CacheBuilder + protected function cache(CacheItemPoolInterface $pool): CacheBuilder { $this->cacheBuilder = new CacheBuilder($pool); return $this->cacheBuilder; } - public function client(ClientInterface $client): ClientBuilder + protected function client(ClientInterface $client): ClientBuilder { $this->clientBuilder->client($client); return $this->clientBuilder; } - public function logger(LoggerInterface $logger): LoggerBuilder + protected function logger(LoggerInterface $logger): LoggerBuilder { $this->loggerBuilder = new LoggerBuilder($logger); diff --git a/src/ApiSetup.php b/src/ApiSetup.php new file mode 100644 index 0000000..edb3b73 --- /dev/null +++ b/src/ApiSetup.php @@ -0,0 +1,111 @@ +call('baseUrl', [$baseUrl]); + + return $this; + } + + public function defaultQuery(string $name, mixed $value): self + { + $this->call('defaultQuery', [$name, $value]); + + return $this; + } + + public function defaultQueries(array $query): self + { + $this->call('defaultQueries', [$query]); + + return $this; + } + + public function defaultHeader(string $name, mixed $value): self + { + $this->call('defaultHeader', [$name, $value]); + + return $this; + } + + public function defaultHeaders(array $headers): self + { + $this->call('defaultHeaders', [$headers]); + + return $this; + } + + public function responses(): ResponseBuilder + { + return $this->call('responses'); + } + + public function errors(): ErrorBuilder + { + return $this->call('errors'); + } + + public function auth(): AuthBuilder + { + return $this->call('auth'); + } + + public function hooks(): HookBuilder + { + return $this->call('hooks'); + } + + public function plugins(): PluginBuilder + { + return $this->call('plugins'); + } + + public function cache(CacheItemPoolInterface $pool): CacheBuilder + { + return $this->call('cache', [$pool]); + } + + public function client(ClientInterface $client): ClientBuilder + { + return $this->call('client', [$client]); + } + + public function logger(LoggerInterface $logger): LoggerBuilder + { + return $this->call('logger', [$logger]); + } + + public function config(?array $values = null): Config + { + return $this->call('config', [$values]); + } + + private function call(string $method, array $arguments = []): mixed + { + return ($this->call)($method, $arguments); + } +} diff --git a/src/Builder/AuthBuilder.php b/src/Builder/AuthBuilder.php index ab8dbb3..af133a3 100644 --- a/src/Builder/AuthBuilder.php +++ b/src/Builder/AuthBuilder.php @@ -16,46 +16,41 @@ class AuthBuilder { - /** @var Authentication[] */ - private array $authentications = []; + private ?Authentication $authentication = null; public function bearer(string $token): self { - return $this->chain(new Bearer($token)); + return $this->use(new Bearer($token)); } public function basic(string $username, string $password): self { - return $this->chain(new BasicAuth($username, $password)); + return $this->use(new BasicAuth($username, $password)); } public function header(string $name, string|array $value): self { - return $this->chain(new Header($name, $value)); + return $this->use(new Header($name, $value)); } public function query(string $name, mixed $value): self { - return $this->chain(new QueryParam([$name => $value])); + return $this->use(new QueryParam([$name => $value])); } public function wsse(string $username, string $password, string $hashAlgorithm = 'sha1'): self { - return $this->chain(new Wsse($username, $password, $hashAlgorithm)); + return $this->use(new Wsse($username, $password, $hashAlgorithm)); } public function conditional(RequestMatcher $matcher, Authentication $authentication): self { - return $this->chain(new RequestConditional($matcher, $authentication)); + return $this->use(new RequestConditional($matcher, $authentication)); } public function chain(Authentication ...$authentications): self { - foreach ($authentications as $authentication) { - $this->authentications[] = $authentication; - } - - return $this; + return $this->use(new Chain($authentications)); } /** @@ -63,21 +58,18 @@ public function chain(Authentication ...$authentications): self */ public function custom(callable $callback): self { - $this->authentications[] = new CallbackAuthentication($callback); + return $this->use(new CallbackAuthentication($callback)); + } + + public function use(Authentication $authentication): self + { + $this->authentication = $authentication; return $this; } public function getAuthentication(): ?Authentication { - if ($this->authentications === []) { - return null; - } - - if (count($this->authentications) === 1) { - return $this->authentications[0]; - } - - return new Chain($this->authentications); + return $this->authentication; } } diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 43ba9ab..bd9b913 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -62,4 +62,27 @@ public function testApiCanSendRequestWithDefaultHeader(): void $this->assertSame('application/json', $client->getLastRequest()->getHeaderLine('Accept')); } + + public function testApiSetupCanConfigureRequestBehavior(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $api = new class extends Api {}; + $setup = $api->setup(); + + $setup->client($client); + $setup + ->baseUrl('https://api.example.com') + ->defaultQuery('locale', 'en') + ->defaultHeader('Accept', 'application/json'); + + $setup->responses()->json(); + + $response = $api->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); + $this->assertSame('application/json', $client->getLastRequest()->getHeaderLine('Accept')); + } } diff --git a/tests/Integration/AuthenticationTest.php b/tests/Integration/AuthenticationTest.php index e3961c1..2a0c8a0 100644 --- a/tests/Integration/AuthenticationTest.php +++ b/tests/Integration/AuthenticationTest.php @@ -108,6 +108,22 @@ public function testChainedAuthenticationCanBeUsed(): void $this->assertSame('chain', $client->getLastRequest()->getHeaderLine('X-Chain-Auth')); } + public function testConfiguredAuthenticationReplacesPreviousAuthentication(): void + { + $client = $this->client(); + + (new JsonApi($client)) + ->useBearerAuth('secret') + ->useQueryAuth('appid', 'key') + ->raw() + ->fetch(); + + parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + + $this->assertSame('', $client->getLastRequest()->getHeaderLine('Authorization')); + $this->assertSame('key', $query['appid']); + } + public function testCustomAuthenticationCallbackCanBeUsed(): void { $client = $this->client(); diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php index 4b28ef4..c160c27 100644 --- a/tests/Integration/CacheTest.php +++ b/tests/Integration/CacheTest.php @@ -20,7 +20,7 @@ public function testSdkUserCanConfigureCache(): void $api = new JsonApi($client); $api - ->cache(new ArrayAdapter()) + ->setup()->cache(new ArrayAdapter()) ->defaultTtl(60); $first = $api->raw()->fetch(); diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php index 0a9186e..8b20335 100644 --- a/tests/Integration/PluginTest.php +++ b/tests/Integration/PluginTest.php @@ -31,7 +31,7 @@ public function testSdkUserCanConfigurePlugins(): void $client = $this->client(responses: 1); $api = new JsonApi($client); - $api->plugins()->add($this->headerPlugin('user'), priority: 20); + $api->setup()->plugins()->add($this->headerPlugin('user'), priority: 20); $api->raw()->fetch(); $this->assertSame(['user'], $client->getLastRequest()->getHeader('X-Plugin-Order')); diff --git a/tests/Unit/Builder/AuthBuilderTest.php b/tests/Unit/Builder/AuthBuilderTest.php index 166867a..6f7e2cd 100644 --- a/tests/Unit/Builder/AuthBuilderTest.php +++ b/tests/Unit/Builder/AuthBuilderTest.php @@ -27,29 +27,46 @@ public function testSingleAuthenticationIsReturnedDirectly(): void $this->assertSame('Bearer token', $request->getHeaderLine('Authorization')); } - public function testMultipleAuthenticationsAreReturnedAsChain(): void + public function testAuthenticationHelpersReplacePreviousAuthentication(): void { $authentication = (new AuthBuilder()) ->bearer('token') ->query('appid', 'key') ->getAuthentication(); + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('', $request->getHeaderLine('Authorization')); + $this->assertSame('appid=key', $request->getUri()->getQuery()); + } + + public function testExplicitChainComposesHttplugAuthenticationObjects(): void + { + $authentication = (new AuthBuilder()) + ->chain( + new Header('X-Api-Key', 'secret'), + new Header('X-Second-Auth', 'second') + ) + ->getAuthentication(); + $this->assertInstanceOf(Chain::class, $authentication); $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); - $this->assertSame('Bearer token', $request->getHeaderLine('Authorization')); - $this->assertSame('appid=key', $request->getUri()->getQuery()); + $this->assertSame('secret', $request->getHeaderLine('X-Api-Key')); + $this->assertSame('second', $request->getHeaderLine('X-Second-Auth')); } - public function testExplicitChainAcceptsHttplugAuthenticationObjects(): void + public function testUseReplacesAuthenticationWithHttplugAuthenticationObject(): void { $authentication = (new AuthBuilder()) - ->chain(new Header('X-Api-Key', 'secret')) + ->bearer('token') + ->use(new Header('X-Api-Key', 'secret')) ->getAuthentication(); $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + $this->assertSame('', $request->getHeaderLine('Authorization')); $this->assertSame('secret', $request->getHeaderLine('X-Api-Key')); } From 9a53d0aefc20fb6739f7d9c683c136767f11ae35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 16:18:32 +0100 Subject: [PATCH 41/88] docs(v3): add design approach --- docs/design-approach.md | 75 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 10 +++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 docs/design-approach.md diff --git a/docs/design-approach.md b/docs/design-approach.md new file mode 100644 index 0000000..042550c --- /dev/null +++ b/docs/design-approach.md @@ -0,0 +1,75 @@ +# Design Approach + +This package is built for two developer audiences: + +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. + +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. + +## SDK Authors + +SDK authors should usually work inside an `Api` subclass and resources. + +```php +final class ExampleApi extends Api +{ + public function __construct(string $apiKey) + { + $this + ->baseUrl('https://api.example.com') + ->defaultHeader('Accept', 'application/json') + ->responses() + ->json(); + + $this->auth()->query('api_key', $apiKey); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +Resources should make endpoint methods feel direct: + +```php +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); + } +} +``` + +## SDK Users + +SDK users should mostly see the API that the SDK author created: + +```php +$user = $api->users()->find(1); +``` + +Advanced setup is still available through one explicit entry point: + +```php +$api->setup()->client($client); +$api->setup()->plugins()->add($plugin); +$api->setup()->auth()->bearer($token); +``` + +This keeps the main SDK autocomplete focused while preserving hackability. + +## Escape Hatch + +If a concrete SDK does not expose an endpoint yet, `send()` can still use the configured SDK pipeline: + +```php +$response = $api->send('GET', '/new-endpoint/{id}', ['id' => 1]); +``` + +That request still uses configured base URL, defaults, auth, plugins, cache, hooks, response decoding, and error handling. diff --git a/docs/index.md b/docs/index.md index c801a18..7ccc4bf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,14 @@ These docs describe how to create API SDKs with this package. -These docs describe the upcoming `v3.0` API. The stable `v2.x` docs remain in the root `README.md` until v3 is released. +This package is built for two developer audiences: + +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. + +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. + +The practical guides below show how to build resources, map responses, and configure the HTTP pipeline. Read [Design Approach](design-approach.md) for more about the reasoning behind the API shape. ## Requirements @@ -31,3 +38,4 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Hooks](hooks.md): run SDK-author callbacks around requests and responses. - [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. - [API Reference](api-reference.md): current v3 authoring methods and contracts. +- [Design Approach](design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. From c0a2db2641ea20235711b5f1b33bafab65c1f6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 16:23:26 +0100 Subject: [PATCH 42/88] test(v3): cover send escape hatch --- docs/api.md | 6 +++ tests/Integration/ApiTest.php | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/docs/api.md b/docs/api.md index 41af3e0..191a065 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,12 @@ $response = $api->send('GET', '/users/{id}', ['id' => 1]); Path parameters are encoded and replaced in `{name}` placeholders. +`send()` still runs through the configured SDK pipeline: + +- Base URL, default query parameters, and default headers. +- Authentication, plugins, cache, and hooks. +- Response decoding and error mapping. + ## `config(?array $values = null): Config` Public. diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index bd9b913..6ef9f63 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -2,12 +2,18 @@ namespace ProgrammatorDev\Api\Test\Integration; +use Http\Client\Common\Plugin; use Http\Mock\Client; +use Http\Promise\Promise; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Api; +use ProgrammatorDev\Api\Context\RequestContext; +use ProgrammatorDev\Api\Context\ResponseContext; use ProgrammatorDev\Api\Http\Method; use ProgrammatorDev\Api\Test\Fixture\FakeApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; +use Psr\Http\Message\RequestInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class ApiTest extends AbstractTestCase { @@ -85,4 +91,93 @@ public function testApiSetupCanConfigureRequestBehavior(): void $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); $this->assertSame('application/json', $client->getLastRequest()->getHeaderLine('Accept')); } + + public function testApiSendUsesConfiguredPipeline(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"ok":false}')); + + $api = new class extends Api {}; + $setup = $api->setup(); + + $setup->client($client); + $setup->baseUrl('https://api.example.com'); + + $setup->auth()->header('X-Auth', 'secret'); + $setup->plugins()->add($this->headerPlugin('X-Plugin', 'plugin')); + $setup->hooks()->beforeRequest( + fn (RequestContext $context): RequestInterface => $context->request()->withHeader('X-Before-Hook', 'before') + ); + $setup->hooks()->afterResponse( + fn (ResponseContext $context): Response => new Response(body: '{"ok":true}') + ); + $setup->responses()->json(); + + $response = $api->send(Method::GET, '/status'); + + $this->assertSame(['ok' => true], $response->data()); + $this->assertSame('secret', $client->getLastRequest()->getHeaderLine('X-Auth')); + $this->assertSame('plugin', $client->getLastRequest()->getHeaderLine('X-Plugin')); + $this->assertSame('before', $client->getLastRequest()->getHeaderLine('X-Before-Hook')); + } + + public function testApiSendUsesConfiguredErrors(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 404, body: '{"message":"Missing"}')); + + $api = new class extends Api {}; + $setup = $api->setup(); + + $setup->client($client); + $setup->baseUrl('https://api.example.com'); + + $setup->responses()->json(); + $setup->errors()->status(404, fn (): \Throwable => new \RuntimeException('Missing')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing'); + + $api->send(Method::GET, '/missing'); + } + + public function testApiSendUsesConfiguredCache(): void + { + $client = new Client(); + $client->addResponse(new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":1}' + )); + + $api = new class extends Api {}; + $setup = $api->setup(); + + $setup->client($client); + $setup->baseUrl('https://api.example.com'); + + $setup->cache(new ArrayAdapter())->defaultTtl(60); + $setup->responses()->json(); + + $first = $api->send(Method::GET, '/users/{id}', ['id' => 1]); + $second = $api->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame(['id' => 1], $first->data()); + $this->assertSame(['id' => 1], $second->data()); + $this->assertCount(1, $client->getRequests()); + } + + private function headerPlugin(string $name, string $value): Plugin + { + return new class($name, $value) implements Plugin { + public function __construct( + private readonly string $name, + private readonly string $value + ) {} + + public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise + { + return $next($request->withHeader($this->name, $this->value)); + } + }; + } } From 1bc36a4ef2a8667468529d1e0f45480c7bf77384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 16:28:14 +0100 Subject: [PATCH 43/88] refactor(v3): rename response envelope mapper --- docs/getting-started.md | 2 +- docs/resource-authoring.md | 6 +++--- docs/responses.md | 6 +++--- docs/v3-architecture-plan.md | 18 +++++++++--------- src/Response/Response.php | 2 +- tests/Fixture/UserResource.php | 2 +- tests/Unit/ResponseTest.php | 8 ++++---- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 414b95f..254cbcf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -159,7 +159,7 @@ public function findWithMeta(int $id): UserResponse { return $this ->get('/users/{id}', ['id' => $id]) - ->as(UserResponse::class); + ->envelope(UserResponse::class); } ``` diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index 8a5d379..7f990b7 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -257,12 +257,12 @@ final class UserResponse implements ResponseEnvelopeInterface Keep context usage focused on hydration decisions. Entities should still be data/value objects by default and should not perform hidden network calls. -Use `as()` when the response carries metadata, pagination, or any API-specific envelope: +Use `envelope()` when the response carries metadata, pagination, or any API-specific envelope: ```php return $this ->get('/users/{id}', ['id' => $id]) - ->as(UserResponse::class); + ->envelope(UserResponse::class); ``` Envelope classes must implement `ResponseEnvelopeInterface`: @@ -317,7 +317,7 @@ final class FixtureResource extends Resource return $this ->include('participants', 'league') ->get('/fixtures/{id}', ['id' => $id]) - ->as(FixtureResponse::class); + ->envelope(FixtureResponse::class); } } ``` diff --git a/docs/responses.md b/docs/responses.md index 70bcc0e..d49f1cf 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -52,14 +52,14 @@ return $this ->collection(User::class, key: 'data'); ``` -### `as(string $class): ResponseEnvelopeInterface` +### `envelope(string $class): ResponseEnvelopeInterface` Maps the response to a custom envelope. ```php return $this ->get('/users/{id}', ['id' => $id]) - ->as(UserResponse::class); + ->envelope(UserResponse::class); ``` The class must implement `ResponseEnvelopeInterface`. @@ -74,7 +74,7 @@ public static function fromArray(array $data, ?Context $context = null): static; ## `ResponseEnvelopeInterface` -Response envelopes used by `Response::as()` must implement: +Response envelopes used by `Response::envelope()` must implement: ```php public static function fromResponse(Response $response, ?Context $context = null): static; diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 87d6623..9a98303 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -145,7 +145,7 @@ interface ResponseEnvelopeInterface } ``` -`Response::as()` should require `ResponseEnvelopeInterface`. +`Response::envelope()` should require `ResponseEnvelopeInterface`. The wrapper should be named `Response`, not `ApiResponse`. PSR responses are referenced through `ResponseInterface`, so the shorter package name is acceptable and keeps SDK authoring readable. @@ -159,7 +159,7 @@ public function find(int $id): User public function findWithMeta(int $id): UserResponse { - return $this->get('/users/{id}', ['id' => $id])->as(UserResponse::class); + return $this->get('/users/{id}', ['id' => $id])->envelope(UserResponse::class); } ``` @@ -325,7 +325,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon | `ResponseContentsEvent` for JSON | Removed; first-class `responses()->json()` | | Post-response listener for errors | First-class status and callback-based error mapping, while preserving hooks | | Raw response body return | `Response::data()` or `Response::raw()` depending on configuration | -| Manual entity construction in resources | `Response::entity(...)`, `Response::collection(...)`, and `Response::as(...)` helpers | +| Manual entity construction in resources | `Response::entity(...)`, `Response::collection(...)`, and `Response::envelope(...)` helpers | ## Decisions @@ -336,8 +336,8 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - SDK-wide options should be stored in a generic config bag. - SDK config should be available through context objects, not by injecting `Api` into entities. - `Response::entity()` and `Response::collection()` should require classes that implement `EntityInterface`. -- `Response::as()` should support API-specific response envelope classes such as item, collection, metadata, and pagination responses. -- `Response::as()` should require a `ResponseEnvelopeInterface` contract with `fromResponse(Response $response, ?Context $context = null)`. +- `Response::envelope()` should support API-specific response envelope classes such as item, collection, metadata, and pagination responses. +- `Response::envelope()` should require a `ResponseEnvelopeInterface` contract with `fromResponse(Response $response, ?Context $context = null)`. - `Response::collection()` should return a plain array by default. - Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. - Symfony EventDispatcher has been replaced with a smaller request/response pipeline. @@ -351,7 +351,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - `get`, `post`, `put`, `patch`, and `delete` should execute immediately. - SDK authors choose whether resource methods return entities directly or custom response envelopes. - Resource constructors may remain public. -- Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::as()`. +- Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::envelope()`. - No reset methods for resource options in the first phase. - Merge order should be global defaults, then resource options, then endpoint-specific options. - Client configuration is global API setup only. Do not add `Resource::client()`. @@ -372,7 +372,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Fluent config should use grouped builders such as `auth()`, `responses()`, `errors()`, `plugins()`, `cache()`, `logger()`, and `hooks()`. - `auth()` should mirror and wrap HTTPlug authentication behavior rather than inventing new authentication primitives. - `Response::entity()` and `Response::collection()` should support an optional key for extracting entity data from decoded response envelopes. -- `Response::as()` should receive the full decoded `Response`, leaving envelope classes responsible for extracting their data. +- `Response::envelope()` should receive the full decoded `Response`, leaving envelope classes responsible for extracting their data. - Response data access should stay simple in the first phase. No dot notation or nested key helpers. - Request body helpers should be friendly for SDK authors while converting to PSR-7 streams internally. - Resource body helpers should be fluent: `json()`, `form()`, and `body()`. @@ -424,7 +424,7 @@ final class UserResource extends Resource return $this ->query('active', true) ->get('/users') - ->as(UserCollection::class); + ->envelope(UserCollection::class); } public function find(int $id): User @@ -445,7 +445,7 @@ final class FixtureResource extends Resource { return $this ->get('/v3/football/fixtures/{id}', ['id' => $id]) - ->as(FixtureItem::class); + ->envelope(FixtureItem::class); } } ``` diff --git a/src/Response/Response.php b/src/Response/Response.php index efd968b..57d67ae 100644 --- a/src/Response/Response.php +++ b/src/Response/Response.php @@ -78,7 +78,7 @@ public function collection(string $class, ?string $key = null): array * @param class-string $class * @return T */ - public function as(string $class): ResponseEnvelopeInterface + public function envelope(string $class): ResponseEnvelopeInterface { $this->assertResponseEnvelopeClass($class); diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 58b70d3..1d3a873 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -65,7 +65,7 @@ public function findEnvelope(int|string $id): UserEnvelope { return $this ->get('/users/{id}', ['id' => $id]) - ->as(UserEnvelope::class); + ->envelope(UserEnvelope::class); } public function findWithEndpointLocale(int|string $id, string $locale): User diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 1ea0b1f..6751c2f 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -32,7 +32,7 @@ public function testEnvelopeReceivesEmptyContextByDefault(): void { $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse()); - $envelope = $response->as(UserEnvelope::class); + $envelope = $response->envelope(UserEnvelope::class); $this->assertNull($envelope->getTimezone()); } @@ -43,7 +43,7 @@ public function testEnvelopeReceivesContext(): void $context = new Context($config); $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse(), $context); - $envelope = $response->as(UserEnvelope::class); + $envelope = $response->envelope(UserEnvelope::class); $this->assertSame('UTC', $envelope->getTimezone()); } @@ -144,7 +144,7 @@ public function testEnvelopeRejectsClassThatDoesNotImplementResponseEnvelope(): $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('must implement'); - $response->as(\stdClass::class); + $response->envelope(\stdClass::class); } public function testEntityRejectsNonArrayData(): void @@ -221,7 +221,7 @@ public function testEnvelopeCanBeMapped(): void { $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse(status: 202)); - $envelope = $response->as(UserEnvelope::class); + $envelope = $response->envelope(UserEnvelope::class); $this->assertSame(202, $envelope->getStatusCode()); $this->assertSame(1, $envelope->getUser()->getId()); From bfd878ed066ade0777cec9d47bcddbe79230fd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 16:40:02 +0100 Subject: [PATCH 44/88] feat(v3): complete resource verb helpers --- docs/resource-authoring.md | 2 ++ docs/resources.md | 6 +++++- src/Helper/StringHelper.php | 6 ++++-- src/Http/Method.php | 2 +- src/Resource.php | 10 ++++++++++ tests/Fixture/UserResource.php | 2 ++ tests/Integration/ResourceTest.php | 2 ++ 7 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index 7f990b7..c7cb641 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -50,6 +50,8 @@ $this->patch('/users/{id}', ['id' => $id]); $this->delete('/users/{id}', ['id' => $id]); $this->head('/users'); $this->options('/users'); +$this->connect('/users'); +$this->trace('/users'); ``` Each helper executes the request immediately and returns a `Response` wrapper. diff --git a/docs/resources.md b/docs/resources.md index 2927bf4..c5ca376 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -96,6 +96,8 @@ $this->patch('/users/{id}', ['id' => $id]); $this->delete('/users/{id}', ['id' => $id]); $this->head('/users'); $this->options('/users'); +$this->connect('/users'); +$this->trace('/users'); ``` All helpers accept: @@ -111,7 +113,9 @@ array $query = [] Protected escape hatch for methods without a named helper. ```php +use ProgrammatorDev\Api\Http\Method; + return $this - ->send('TRACE', '/debug') + ->send(Method::TRACE, '/debug') ->raw(); ``` diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php index f15612d..07fe401 100644 --- a/src/Helper/StringHelper.php +++ b/src/Helper/StringHelper.php @@ -2,8 +2,10 @@ namespace ProgrammatorDev\Api\Helper; -class StringHelper +final class StringHelper { + private function __construct() {} + public static function reduceDuplicateSlashes(string $string): string { return preg_replace('#(^|[^:])//+#', '\\1/', $string); @@ -13,4 +15,4 @@ public static function isUrl(string $string): bool { return filter_var($string, FILTER_VALIDATE_URL) !== false; } -} \ No newline at end of file +} diff --git a/src/Http/Method.php b/src/Http/Method.php index 5611c98..e973d21 100644 --- a/src/Http/Method.php +++ b/src/Http/Method.php @@ -2,7 +2,7 @@ namespace ProgrammatorDev\Api\Http; -class Method +final class Method { public const GET = 'GET'; public const HEAD = 'HEAD'; diff --git a/src/Resource.php b/src/Resource.php index 213a4bb..4e9e268 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -101,6 +101,16 @@ protected function options(string $path, array $pathParams = [], array $query = return $this->send(Method::OPTIONS, $path, $pathParams, $query); } + protected function connect(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::CONNECT, $path, $pathParams, $query); + } + + protected function trace(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::TRACE, $path, $pathParams, $query); + } + protected function config(): Config { return $this->api->config(); diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 1d3a873..82e7983 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -17,6 +17,8 @@ public function sendWithVerb(string $verb): void 'DELETE' => $this->delete('/users/{id}', ['id' => 1]), 'HEAD' => $this->head('/users'), 'OPTIONS' => $this->options('/users'), + 'CONNECT' => $this->connect('/users'), + 'TRACE' => $this->trace('/users'), }; } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 6b3e4d8..42215af 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -216,6 +216,8 @@ public static function resourceVerbProvider(): array 'delete' => ['DELETE'], 'head' => ['HEAD'], 'options' => ['OPTIONS'], + 'connect' => ['CONNECT'], + 'trace' => ['TRACE'], ]; } } From a2dfe09cdd2dc5219bcd29caed6450c5fda7b68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 16:42:56 +0100 Subject: [PATCH 45/88] refactor(v3): replace string helper with url helper --- docs/v3-architecture-plan.md | 4 +- src/Helper/StringHelper.php | 18 -------- src/Helper/UrlHelper.php | 33 +++++++++++++ src/Http/Transport.php | 7 +-- tests/Unit/Helper/StringHelperTest.php | 17 ------- tests/Unit/Helper/UrlHelperTest.php | 64 ++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 42 deletions(-) delete mode 100644 src/Helper/StringHelper.php create mode 100644 src/Helper/UrlHelper.php delete mode 100644 tests/Unit/Helper/StringHelperTest.php create mode 100644 tests/Unit/Helper/UrlHelperTest.php diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 9a98303..97c53da 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -295,8 +295,8 @@ Error handling should run before transform hooks so API-specific error mapping s Current helpers: -- `StringHelper::reduceDuplicateSlashes` -- `StringHelper::isUrl` +- `UrlHelper::join` +- `UrlHelper::isAbsoluteUrl` - `Method` constants. Current test utilities: diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php deleted file mode 100644 index 07fe401..0000000 --- a/src/Helper/StringHelper.php +++ /dev/null @@ -1,18 +0,0 @@ - $value !== null); $appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); - if (StringHelper::isUrl($path)) { - return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); - } + $url = UrlHelper::join($this->baseUrl, $path); - $url = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); } diff --git a/tests/Unit/Helper/StringHelperTest.php b/tests/Unit/Helper/StringHelperTest.php deleted file mode 100644 index 31d3955..0000000 --- a/tests/Unit/Helper/StringHelperTest.php +++ /dev/null @@ -1,17 +0,0 @@ -assertSame( - 'https://example.com/path/test', - StringHelper::reduceDuplicateSlashes('https://example.com////path//test') - ); - } -} \ No newline at end of file diff --git a/tests/Unit/Helper/UrlHelperTest.php b/tests/Unit/Helper/UrlHelperTest.php new file mode 100644 index 0000000..53272b1 --- /dev/null +++ b/tests/Unit/Helper/UrlHelperTest.php @@ -0,0 +1,64 @@ +assertSame($expected, UrlHelper::join($baseUrl, $path)); + } + + public static function urlProvider(): array + { + return [ + 'base trailing slash and path leading slash' => [ + 'https://api.example.com/', + '/users', + 'https://api.example.com/users', + ], + 'base without trailing slash and path without leading slash' => [ + 'https://api.example.com', + 'users', + 'https://api.example.com/users', + ], + 'base path is preserved' => [ + 'https://api.example.com/v1/', + '/users', + 'https://api.example.com/v1/users', + ], + 'duplicate path slashes are reduced' => [ + 'https://api.example.com/', + '//users//1', + 'https://api.example.com/users/1', + ], + 'absolute URL overrides base URL' => [ + 'https://api.example.com', + 'https://other.example.com/users', + 'https://other.example.com/users', + ], + 'scheme slashes are preserved' => [ + null, + 'https://api.example.com//users', + 'https://api.example.com//users', + ], + 'relative path works without base URL' => [ + null, + '/users//1', + '/users/1', + ], + ]; + } + + public function testIsAbsoluteUrl(): void + { + $this->assertTrue(UrlHelper::isAbsoluteUrl('https://api.example.com/users')); + $this->assertFalse(UrlHelper::isAbsoluteUrl('/users')); + } +} From 1eca1000d334e95986bf49e61977efeb80352fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 17:09:24 +0100 Subject: [PATCH 46/88] feat(v3): support config defaults --- docs/api.md | 17 ++++++++++++++--- docs/resource-authoring.md | 4 +++- docs/v3-architecture-plan.md | 4 ++-- src/Api.php | 8 ++++++-- src/ApiSetup.php | 4 ++-- src/Config/Config.php | 13 +++++++++++++ tests/Integration/ApiTest.php | 2 +- tests/Unit/ConfigTest.php | 14 ++++++++++++++ 8 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/api.md b/docs/api.md index 191a065..7ee04c2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,14 +22,17 @@ Path parameters are encoded and replaced in `{name}` placeholders. - Authentication, plugins, cache, and hooks. - Response decoding and error mapping. -## `config(?array $values = null): Config` +## `config(array $values = [], array $defaults = []): Config` Public. -Sets SDK options when an array is provided and always returns the config bag. +Merges SDK options when values or defaults are provided and always returns the config bag. Defaults are merged first, so explicit values override them. ```php -$this->config(['timezone' => 'UTC']); +$this->config( + ['timezone' => 'UTC'], + defaults: ['timezone' => 'Europe/Lisbon'] +); $timezone = $this->config()->get('timezone'); ``` @@ -285,6 +288,14 @@ Returns all option values. $options = $api->config()->all(); ``` +### `only(string ...$keys): array` + +Returns selected option values. Missing keys are omitted. + +```php +$query = $api->config()->only('units', 'lang'); +``` + ### `has(string $key): bool` Checks whether an option exists. A key with a `null` value still exists. diff --git a/docs/resource-authoring.md b/docs/resource-authoring.md index c7cb641..f22aee2 100644 --- a/docs/resource-authoring.md +++ b/docs/resource-authoring.md @@ -198,7 +198,9 @@ final class ExampleApi extends Api ->baseUrl('https://api.example.com') ->defaultQueries(['api_key' => $apiKey]); - $this->config($options); + $this->config($options, defaults: [ + 'timezone' => 'UTC', + ]); } } ``` diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 97c53da..cad4774 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -47,10 +47,10 @@ Non-goals: `Api` should be abstract. It is a base class for concrete SDKs, not something users instantiate directly. It does not need to force subclasses to implement an abstract method in the first phase. -`config()` should act as both setter and getter: +`config()` should always return the config bag. When values or defaults are provided, it merges defaults first and explicit values second: ```php -$this->config(['timezone' => 'UTC']); +$this->config(['timezone' => 'UTC'], defaults: ['timezone' => 'Europe/Lisbon']); $timezone = $this->config()->get('timezone'); ``` diff --git a/src/Api.php b/src/Api.php index 766d2fc..7f3b4f4 100644 --- a/src/Api.php +++ b/src/Api.php @@ -192,9 +192,13 @@ protected function logger(LoggerInterface $logger): LoggerBuilder return $this->loggerBuilder; } - public function config(?array $values = null): Config + public function config(array $values = [], array $defaults = []): Config { - if ($values !== null) { + if ($defaults !== []) { + $this->config->merge($defaults); + } + + if ($values !== []) { $this->config->merge($values); } diff --git a/src/ApiSetup.php b/src/ApiSetup.php index edb3b73..970fe4f 100644 --- a/src/ApiSetup.php +++ b/src/ApiSetup.php @@ -99,9 +99,9 @@ public function logger(LoggerInterface $logger): LoggerBuilder return $this->call('logger', [$logger]); } - public function config(?array $values = null): Config + public function config(array $values = [], array $defaults = []): Config { - return $this->call('config', [$values]); + return $this->call('config', [$values, $defaults]); } private function call(string $method, array $arguments = []): mixed diff --git a/src/Config/Config.php b/src/Config/Config.php index d830bf2..feae06b 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -13,6 +13,19 @@ public function all(): array return $this->values; } + public function only(string ...$keys): array + { + $values = []; + + foreach ($keys as $key) { + if ($this->has($key)) { + $values[$key] = $this->values[$key]; + } + } + + return $values; + } + public function has(string $key): bool { return array_key_exists($key, $this->values); diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 6ef9f63..ad0b274 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -22,7 +22,7 @@ public function testConfigCanBeSetAndReadBySdkApi(): void $api = new class extends Api {}; $api - ->config(['timezone' => 'UTC']) + ->config(['timezone' => 'UTC'], defaults: ['timezone' => 'Europe/Lisbon']) ->merge(['units' => 'metric']); $this->assertSame('UTC', $api->config()->get('timezone')); diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 63545ec..9f54e29 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -45,4 +45,18 @@ public function testConfigCanBeUpdated(): void 'units' => 'metric', ], $config->all()); } + + public function testConfigCanReturnOnlySelectedValues(): void + { + $config = new Config([ + 'timezone' => 'UTC', + 'units' => 'metric', + 'internal' => true, + ]); + + $this->assertSame([ + 'timezone' => 'UTC', + 'units' => 'metric', + ], $config->only('timezone', 'units', 'missing')); + } } From c37932fcb6b539fea79f9ce93e8a6bff3ae7ab1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 17:09:57 +0100 Subject: [PATCH 47/88] test(v3): add simple api proof --- docs/v3-architecture-plan.md | 2 +- tests/Fixture/Weather/CurrentWeather.php | 53 +++++++++++++ tests/Fixture/Weather/WeatherApi.php | 28 +++++++ tests/Fixture/Weather/WeatherResource.php | 32 ++++++++ tests/Fixture/Weather/WeatherResponse.php | 40 ++++++++++ tests/Integration/SimpleApiProofTest.php | 97 +++++++++++++++++++++++ 6 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 tests/Fixture/Weather/CurrentWeather.php create mode 100644 tests/Fixture/Weather/WeatherApi.php create mode 100644 tests/Fixture/Weather/WeatherResource.php create mode 100644 tests/Fixture/Weather/WeatherResponse.php create mode 100644 tests/Integration/SimpleApiProofTest.php diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index cad4774..4371878 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -534,5 +534,5 @@ Before tagging v3: - [x] SDK author test fixtures. - [ ] README update. - [ ] `UPGRADE-3.0.md`. -- [ ] Simple API proof. +- [x] Simple API proof. - [ ] Complex API proof. diff --git a/tests/Fixture/Weather/CurrentWeather.php b/tests/Fixture/Weather/CurrentWeather.php new file mode 100644 index 0000000..90dec78 --- /dev/null +++ b/tests/Fixture/Weather/CurrentWeather.php @@ -0,0 +1,53 @@ +config()->get('units'), + lang: $context?->config()->get('lang') + ); + } + + public function getCity(): string + { + return $this->city; + } + + public function getTemperature(): float + { + return $this->temperature; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getUnits(): ?string + { + return $this->units; + } + + public function getLang(): ?string + { + return $this->lang; + } +} diff --git a/tests/Fixture/Weather/WeatherApi.php b/tests/Fixture/Weather/WeatherApi.php new file mode 100644 index 0000000..66f6a83 --- /dev/null +++ b/tests/Fixture/Weather/WeatherApi.php @@ -0,0 +1,28 @@ +config($options, defaults: [ + 'units' => 'metric', + 'lang' => 'en', + ]); + + $this->baseUrl('https://api.openweathermap.org/data/2.5'); + $this->auth()->query('appid', $apiKey); + $this->defaultQueries($this->config()->only('units', 'lang')); + $this->responses()->json(); + } + + public function weather(): WeatherResource + { + return $this->resource(WeatherResource::class); + } +} diff --git a/tests/Fixture/Weather/WeatherResource.php b/tests/Fixture/Weather/WeatherResource.php new file mode 100644 index 0000000..54a6a41 --- /dev/null +++ b/tests/Fixture/Weather/WeatherResource.php @@ -0,0 +1,32 @@ +get('/weather', query: ['q' => $city]) + ->entity(CurrentWeather::class); + } + + public function currentResponse(string $city): WeatherResponse + { + return $this + ->get('/weather', query: ['q' => $city]) + ->envelope(WeatherResponse::class); + } + + /** + * @return CurrentWeather[] + */ + public function group(string ...$cities): array + { + return $this + ->get('/group', query: ['q' => implode(',', $cities)]) + ->collection(CurrentWeather::class, key: 'list'); + } +} diff --git a/tests/Fixture/Weather/WeatherResponse.php b/tests/Fixture/Weather/WeatherResponse.php new file mode 100644 index 0000000..70e1d25 --- /dev/null +++ b/tests/Fixture/Weather/WeatherResponse.php @@ -0,0 +1,40 @@ +entity(CurrentWeather::class), + statusCode: $response->raw()->getStatusCode(), + units: $context?->config()->get('units') + ); + } + + public function getWeather(): CurrentWeather + { + return $this->weather; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getUnits(): ?string + { + return $this->units; + } +} diff --git a/tests/Integration/SimpleApiProofTest.php b/tests/Integration/SimpleApiProofTest.php new file mode 100644 index 0000000..ea94afa --- /dev/null +++ b/tests/Integration/SimpleApiProofTest.php @@ -0,0 +1,97 @@ +addResponse(new Response(body: self::WEATHER_RESPONSE)); + + $api = new WeatherApi('test-key', ['units' => 'imperial', 'lang' => 'pt']); + $api->setup()->client($client); + + $weather = $api->weather()->current('Lisbon'); + + $this->assertInstanceOf(CurrentWeather::class, $weather); + $this->assertSame('Lisbon', $weather->getCity()); + $this->assertSame(21.5, $weather->getTemperature()); + $this->assertSame('clear sky', $weather->getDescription()); + $this->assertSame('imperial', $weather->getUnits()); + $this->assertSame('pt', $weather->getLang()); + + parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + + $this->assertSame('/data/2.5/weather', $client->getLastRequest()->getUri()->getPath()); + $this->assertSame('Lisbon', $query['q']); + $this->assertSame('test-key', $query['appid']); + $this->assertSame('imperial', $query['units']); + $this->assertSame('pt', $query['lang']); + } + + public function testCurrentWeatherCanBeMappedToEnvelope(): void + { + $client = new Client(); + $client->addResponse(new Response(status: 202, body: self::WEATHER_RESPONSE)); + + $api = new WeatherApi('test-key'); + $api->setup()->client($client); + + $response = $api->weather()->currentResponse('Lisbon'); + + $this->assertInstanceOf(WeatherResponse::class, $response); + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('metric', $response->getUnits()); + $this->assertSame('Lisbon', $response->getWeather()->getCity()); + $this->assertSame('metric', $response->getWeather()->getUnits()); + $this->assertSame('en', $response->getWeather()->getLang()); + } + + public function testWeatherGroupCanBeMappedToCollection(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{ + "list": [ + { + "name": "Lisbon", + "main": {"temp": 21.5}, + "weather": [{"description": "clear sky"}] + }, + { + "name": "Porto", + "main": {"temp": 18.0}, + "weather": [{"description": "few clouds"}] + } + ] + }')); + + $api = new WeatherApi('test-key', ['units' => 'metric']); + $api->setup()->client($client); + + $weather = $api->weather()->group('Lisbon', 'Porto'); + + $this->assertContainsOnlyInstancesOf(CurrentWeather::class, $weather); + $this->assertSame('Lisbon', $weather[0]->getCity()); + $this->assertSame('Porto', $weather[1]->getCity()); + $this->assertSame('metric', $weather[0]->getUnits()); + + parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + + $this->assertSame('/data/2.5/group', $client->getLastRequest()->getUri()->getPath()); + $this->assertSame('Lisbon,Porto', $query['q']); + } +} From 4f6f1c01739a965056166626dbe3d152f446e984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 17:15:13 +0100 Subject: [PATCH 48/88] test(v3): make simple api proof generic --- tests/Fixture/Simple/SimpleApi.php | 28 ++++++ tests/Fixture/Simple/SimpleEntity.php | 46 ++++++++++ tests/Fixture/Simple/SimpleResource.php | 32 +++++++ .../SimpleResponse.php} | 20 ++-- tests/Fixture/Weather/CurrentWeather.php | 53 ----------- tests/Fixture/Weather/WeatherApi.php | 28 ------ tests/Fixture/Weather/WeatherResource.php | 32 ------- tests/Integration/SimpleApiProofTest.php | 91 ++++++++----------- 8 files changed, 156 insertions(+), 174 deletions(-) create mode 100644 tests/Fixture/Simple/SimpleApi.php create mode 100644 tests/Fixture/Simple/SimpleEntity.php create mode 100644 tests/Fixture/Simple/SimpleResource.php rename tests/Fixture/{Weather/WeatherResponse.php => Simple/SimpleResponse.php} (54%) delete mode 100644 tests/Fixture/Weather/CurrentWeather.php delete mode 100644 tests/Fixture/Weather/WeatherApi.php delete mode 100644 tests/Fixture/Weather/WeatherResource.php diff --git a/tests/Fixture/Simple/SimpleApi.php b/tests/Fixture/Simple/SimpleApi.php new file mode 100644 index 0000000..d397db6 --- /dev/null +++ b/tests/Fixture/Simple/SimpleApi.php @@ -0,0 +1,28 @@ +config($options, defaults: [ + 'locale' => 'en', + 'version' => 'v1', + ]); + + $this->baseUrl('https://api.example.com'); + $this->auth()->query('api_key', $apiKey); + $this->defaultQueries($this->config()->only('locale', 'version')); + $this->responses()->json(); + } + + public function items(): SimpleResource + { + return $this->resource(SimpleResource::class); + } +} diff --git a/tests/Fixture/Simple/SimpleEntity.php b/tests/Fixture/Simple/SimpleEntity.php new file mode 100644 index 0000000..e765683 --- /dev/null +++ b/tests/Fixture/Simple/SimpleEntity.php @@ -0,0 +1,46 @@ +config()->get('locale'), + version: $context?->config()->get('version') + ); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getLocale(): ?string + { + return $this->locale; + } + + public function getVersion(): ?string + { + return $this->version; + } +} diff --git a/tests/Fixture/Simple/SimpleResource.php b/tests/Fixture/Simple/SimpleResource.php new file mode 100644 index 0000000..3fbf50b --- /dev/null +++ b/tests/Fixture/Simple/SimpleResource.php @@ -0,0 +1,32 @@ +get('/items/{id}', ['id' => $id]) + ->entity(SimpleEntity::class); + } + + public function findResponse(int|string $id): SimpleResponse + { + return $this + ->get('/items/{id}', ['id' => $id]) + ->envelope(SimpleResponse::class); + } + + /** + * @return SimpleEntity[] + */ + public function all(): array + { + return $this + ->get('/items') + ->collection(SimpleEntity::class, key: 'data'); + } +} diff --git a/tests/Fixture/Weather/WeatherResponse.php b/tests/Fixture/Simple/SimpleResponse.php similarity index 54% rename from tests/Fixture/Weather/WeatherResponse.php rename to tests/Fixture/Simple/SimpleResponse.php index 70e1d25..6c44c1b 100644 --- a/tests/Fixture/Weather/WeatherResponse.php +++ b/tests/Fixture/Simple/SimpleResponse.php @@ -1,31 +1,31 @@ entity(CurrentWeather::class), + entity: $response->entity(SimpleEntity::class), statusCode: $response->raw()->getStatusCode(), - units: $context?->config()->get('units') + locale: $context?->config()->get('locale') ); } - public function getWeather(): CurrentWeather + public function getEntity(): SimpleEntity { - return $this->weather; + return $this->entity; } public function getStatusCode(): int @@ -33,8 +33,8 @@ public function getStatusCode(): int return $this->statusCode; } - public function getUnits(): ?string + public function getLocale(): ?string { - return $this->units; + return $this->locale; } } diff --git a/tests/Fixture/Weather/CurrentWeather.php b/tests/Fixture/Weather/CurrentWeather.php deleted file mode 100644 index 90dec78..0000000 --- a/tests/Fixture/Weather/CurrentWeather.php +++ /dev/null @@ -1,53 +0,0 @@ -config()->get('units'), - lang: $context?->config()->get('lang') - ); - } - - public function getCity(): string - { - return $this->city; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getDescription(): string - { - return $this->description; - } - - public function getUnits(): ?string - { - return $this->units; - } - - public function getLang(): ?string - { - return $this->lang; - } -} diff --git a/tests/Fixture/Weather/WeatherApi.php b/tests/Fixture/Weather/WeatherApi.php deleted file mode 100644 index 66f6a83..0000000 --- a/tests/Fixture/Weather/WeatherApi.php +++ /dev/null @@ -1,28 +0,0 @@ -config($options, defaults: [ - 'units' => 'metric', - 'lang' => 'en', - ]); - - $this->baseUrl('https://api.openweathermap.org/data/2.5'); - $this->auth()->query('appid', $apiKey); - $this->defaultQueries($this->config()->only('units', 'lang')); - $this->responses()->json(); - } - - public function weather(): WeatherResource - { - return $this->resource(WeatherResource::class); - } -} diff --git a/tests/Fixture/Weather/WeatherResource.php b/tests/Fixture/Weather/WeatherResource.php deleted file mode 100644 index 54a6a41..0000000 --- a/tests/Fixture/Weather/WeatherResource.php +++ /dev/null @@ -1,32 +0,0 @@ -get('/weather', query: ['q' => $city]) - ->entity(CurrentWeather::class); - } - - public function currentResponse(string $city): WeatherResponse - { - return $this - ->get('/weather', query: ['q' => $city]) - ->envelope(WeatherResponse::class); - } - - /** - * @return CurrentWeather[] - */ - public function group(string ...$cities): array - { - return $this - ->get('/group', query: ['q' => implode(',', $cities)]) - ->collection(CurrentWeather::class, key: 'list'); - } -} diff --git a/tests/Integration/SimpleApiProofTest.php b/tests/Integration/SimpleApiProofTest.php index ea94afa..ef4098e 100644 --- a/tests/Integration/SimpleApiProofTest.php +++ b/tests/Integration/SimpleApiProofTest.php @@ -4,94 +4,83 @@ use Http\Mock\Client; use Nyholm\Psr7\Response; -use ProgrammatorDev\Api\Test\Fixture\Weather\CurrentWeather; -use ProgrammatorDev\Api\Test\Fixture\Weather\WeatherApi; -use ProgrammatorDev\Api\Test\Fixture\Weather\WeatherResponse; +use ProgrammatorDev\Api\Test\Fixture\Simple\SimpleApi; +use ProgrammatorDev\Api\Test\Fixture\Simple\SimpleEntity; +use ProgrammatorDev\Api\Test\Fixture\Simple\SimpleResponse; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; class SimpleApiProofTest extends AbstractTestCase { - private const WEATHER_RESPONSE = '{ - "name": "Lisbon", - "main": {"temp": 21.5}, - "weather": [{"description": "clear sky"}] + private const ITEM_RESPONSE = '{ + "id": 1, + "name": "First item" }'; - public function testCurrentWeatherCanBeMappedToEntity(): void + public function testItemCanBeMappedToEntity(): void { $client = new Client(); - $client->addResponse(new Response(body: self::WEATHER_RESPONSE)); + $client->addResponse(new Response(body: self::ITEM_RESPONSE)); - $api = new WeatherApi('test-key', ['units' => 'imperial', 'lang' => 'pt']); + $api = new SimpleApi('test-key', ['locale' => 'pt', 'version' => 'v2']); $api->setup()->client($client); - $weather = $api->weather()->current('Lisbon'); + $item = $api->items()->find(1); - $this->assertInstanceOf(CurrentWeather::class, $weather); - $this->assertSame('Lisbon', $weather->getCity()); - $this->assertSame(21.5, $weather->getTemperature()); - $this->assertSame('clear sky', $weather->getDescription()); - $this->assertSame('imperial', $weather->getUnits()); - $this->assertSame('pt', $weather->getLang()); + $this->assertInstanceOf(SimpleEntity::class, $item); + $this->assertSame(1, $item->getId()); + $this->assertSame('First item', $item->getName()); + $this->assertSame('pt', $item->getLocale()); + $this->assertSame('v2', $item->getVersion()); parse_str($client->getLastRequest()->getUri()->getQuery(), $query); - $this->assertSame('/data/2.5/weather', $client->getLastRequest()->getUri()->getPath()); - $this->assertSame('Lisbon', $query['q']); - $this->assertSame('test-key', $query['appid']); - $this->assertSame('imperial', $query['units']); - $this->assertSame('pt', $query['lang']); + $this->assertSame('/items/1', $client->getLastRequest()->getUri()->getPath()); + $this->assertSame('test-key', $query['api_key']); + $this->assertSame('pt', $query['locale']); + $this->assertSame('v2', $query['version']); } - public function testCurrentWeatherCanBeMappedToEnvelope(): void + public function testItemCanBeMappedToEnvelope(): void { $client = new Client(); - $client->addResponse(new Response(status: 202, body: self::WEATHER_RESPONSE)); + $client->addResponse(new Response(status: 202, body: self::ITEM_RESPONSE)); - $api = new WeatherApi('test-key'); + $api = new SimpleApi('test-key'); $api->setup()->client($client); - $response = $api->weather()->currentResponse('Lisbon'); + $response = $api->items()->findResponse(1); - $this->assertInstanceOf(WeatherResponse::class, $response); + $this->assertInstanceOf(SimpleResponse::class, $response); $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('metric', $response->getUnits()); - $this->assertSame('Lisbon', $response->getWeather()->getCity()); - $this->assertSame('metric', $response->getWeather()->getUnits()); - $this->assertSame('en', $response->getWeather()->getLang()); + $this->assertSame('en', $response->getLocale()); + $this->assertSame(1, $response->getEntity()->getId()); + $this->assertSame('en', $response->getEntity()->getLocale()); + $this->assertSame('v1', $response->getEntity()->getVersion()); } - public function testWeatherGroupCanBeMappedToCollection(): void + public function testItemsCanBeMappedToCollection(): void { $client = new Client(); $client->addResponse(new Response(body: '{ - "list": [ - { - "name": "Lisbon", - "main": {"temp": 21.5}, - "weather": [{"description": "clear sky"}] - }, - { - "name": "Porto", - "main": {"temp": 18.0}, - "weather": [{"description": "few clouds"}] - } + "data": [ + {"id": 1, "name": "First item"}, + {"id": 2, "name": "Second item"} ] }')); - $api = new WeatherApi('test-key', ['units' => 'metric']); + $api = new SimpleApi('test-key', ['locale' => 'pt']); $api->setup()->client($client); - $weather = $api->weather()->group('Lisbon', 'Porto'); + $items = $api->items()->all(); - $this->assertContainsOnlyInstancesOf(CurrentWeather::class, $weather); - $this->assertSame('Lisbon', $weather[0]->getCity()); - $this->assertSame('Porto', $weather[1]->getCity()); - $this->assertSame('metric', $weather[0]->getUnits()); + $this->assertContainsOnlyInstancesOf(SimpleEntity::class, $items); + $this->assertSame('First item', $items[0]->getName()); + $this->assertSame('Second item', $items[1]->getName()); + $this->assertSame('pt', $items[0]->getLocale()); parse_str($client->getLastRequest()->getUri()->getQuery(), $query); - $this->assertSame('/data/2.5/group', $client->getLastRequest()->getUri()->getPath()); - $this->assertSame('Lisbon,Porto', $query['q']); + $this->assertSame('/items', $client->getLastRequest()->getUri()->getPath()); + $this->assertSame('test-key', $query['api_key']); } } From b94cd278ae2315dd8fc4ec2c9d86d115a7cb3d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 17:33:12 +0100 Subject: [PATCH 49/88] test(v3): tidy sdk test fixtures --- tests/Fixture/AuthStatePlugin.php | 19 +++++++++ tests/Fixture/HeaderPlugin.php | 25 +++++++++++ tests/Integration/ApiTest.php | 42 ++++--------------- tests/Integration/AuthenticationTest.php | 34 ++++++--------- tests/Integration/CacheTest.php | 4 +- tests/Integration/ErrorHandlingTest.php | 19 +++------ tests/Integration/HookTest.php | 23 ++++------ tests/Integration/PluginTest.php | 49 ++++++---------------- tests/Integration/ResponseDecodingTest.php | 22 ++++------ tests/Integration/SimpleApiProofTest.php | 14 +++---- tests/Support/AbstractTestCase.php | 20 +++++++++ tests/Unit/Builder/CacheBuilderTest.php | 10 ++--- tests/Unit/Builder/ClientBuilderTest.php | 15 ++++--- tests/Unit/Builder/LoggerBuilderTest.php | 16 +++---- 14 files changed, 143 insertions(+), 169 deletions(-) create mode 100644 tests/Fixture/AuthStatePlugin.php create mode 100644 tests/Fixture/HeaderPlugin.php diff --git a/tests/Fixture/AuthStatePlugin.php b/tests/Fixture/AuthStatePlugin.php new file mode 100644 index 0000000..9b75b06 --- /dev/null +++ b/tests/Fixture/AuthStatePlugin.php @@ -0,0 +1,19 @@ +hasHeader('Authorization') ? 'present' : 'missing'; + + return $next($request->withAddedHeader('X-Auth-State', sprintf('%s:%s', $this->label, $state))); + } +} diff --git a/tests/Fixture/HeaderPlugin.php b/tests/Fixture/HeaderPlugin.php new file mode 100644 index 0000000..b518c16 --- /dev/null +++ b/tests/Fixture/HeaderPlugin.php @@ -0,0 +1,25 @@ +append + ? $request->withAddedHeader($this->name, $this->value) + : $request->withHeader($this->name, $this->value); + + return $next($request); + } +} diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index ad0b274..6f4574a 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -2,15 +2,13 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Client\Common\Plugin; -use Http\Mock\Client; -use Http\Promise\Promise; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Api; use ProgrammatorDev\Api\Context\RequestContext; use ProgrammatorDev\Api\Context\ResponseContext; use ProgrammatorDev\Api\Http\Method; use ProgrammatorDev\Api\Test\Fixture\FakeApi; +use ProgrammatorDev\Api\Test\Fixture\HeaderPlugin; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Http\Message\RequestInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -36,8 +34,7 @@ public function testConfigCanBeSetAndReadBySdkApi(): void public function testApiCanSendPublicRequest(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); $response = (new FakeApi($client))->send(Method::GET, '/users/{id}', ['id' => 1]); @@ -47,8 +44,7 @@ public function testApiCanSendPublicRequest(): void public function testApiCanSendRequestWithDefaultQuery(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); (new FakeApi($client)) ->withDefaultQuery('units', 'metric') @@ -59,8 +55,7 @@ public function testApiCanSendRequestWithDefaultQuery(): void public function testApiCanSendRequestWithDefaultHeader(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); (new FakeApi($client)) ->withDefaultHeader('Accept', 'application/json') @@ -71,8 +66,7 @@ public function testApiCanSendRequestWithDefaultHeader(): void public function testApiSetupCanConfigureRequestBehavior(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); $api = new class extends Api {}; $setup = $api->setup(); @@ -94,8 +88,7 @@ public function testApiSetupCanConfigureRequestBehavior(): void public function testApiSendUsesConfiguredPipeline(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"ok":false}')); + $client = $this->mockClient(new Response(body: '{"ok":false}')); $api = new class extends Api {}; $setup = $api->setup(); @@ -104,7 +97,7 @@ public function testApiSendUsesConfiguredPipeline(): void $setup->baseUrl('https://api.example.com'); $setup->auth()->header('X-Auth', 'secret'); - $setup->plugins()->add($this->headerPlugin('X-Plugin', 'plugin')); + $setup->plugins()->add(new HeaderPlugin('X-Plugin', 'plugin')); $setup->hooks()->beforeRequest( fn (RequestContext $context): RequestInterface => $context->request()->withHeader('X-Before-Hook', 'before') ); @@ -123,8 +116,7 @@ public function testApiSendUsesConfiguredPipeline(): void public function testApiSendUsesConfiguredErrors(): void { - $client = new Client(); - $client->addResponse(new Response(status: 404, body: '{"message":"Missing"}')); + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing"}')); $api = new class extends Api {}; $setup = $api->setup(); @@ -143,8 +135,7 @@ public function testApiSendUsesConfiguredErrors(): void public function testApiSendUsesConfiguredCache(): void { - $client = new Client(); - $client->addResponse(new Response( + $client = $this->mockClient(new Response( headers: ['Cache-Control' => 'max-age=60'], body: '{"id":1}' )); @@ -165,19 +156,4 @@ public function testApiSendUsesConfiguredCache(): void $this->assertSame(['id' => 1], $second->data()); $this->assertCount(1, $client->getRequests()); } - - private function headerPlugin(string $name, string $value): Plugin - { - return new class($name, $value) implements Plugin { - public function __construct( - private readonly string $name, - private readonly string $value - ) {} - - public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise - { - return $next($request->withHeader($this->name, $this->value)); - } - }; - } } diff --git a/tests/Integration/AuthenticationTest.php b/tests/Integration/AuthenticationTest.php index 2a0c8a0..464d86a 100644 --- a/tests/Integration/AuthenticationTest.php +++ b/tests/Integration/AuthenticationTest.php @@ -2,8 +2,6 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Mock\Client; -use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\JsonApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; @@ -11,7 +9,7 @@ class AuthenticationTest extends AbstractTestCase { public function testBearerAuthenticationAddsAuthorizationHeader(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useBearerAuth('secret') @@ -23,7 +21,7 @@ public function testBearerAuthenticationAddsAuthorizationHeader(): void public function testBasicAuthenticationAddsAuthorizationHeader(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useBasicAuth('user', 'pass') @@ -35,7 +33,7 @@ public function testBasicAuthenticationAddsAuthorizationHeader(): void public function testHeaderAuthenticationAddsConfiguredHeader(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useHeaderAuth('X-Api-Key', 'secret') @@ -47,21 +45,21 @@ public function testHeaderAuthenticationAddsConfiguredHeader(): void public function testQueryAuthenticationAddsConfiguredQueryParameter(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useQueryAuth('appid', 'secret') ->raw() ->fetch(); - parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + $query = $this->queryFromLastRequest($client); $this->assertSame('secret', $query['appid']); } public function testWsseAuthenticationAddsWsseHeaders(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useWsseAuth('user', 'pass') @@ -74,7 +72,7 @@ public function testWsseAuthenticationAddsWsseHeaders(): void public function testConditionalAuthenticationAddsAuthenticationWhenMatched(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useConditionalAuth() @@ -86,7 +84,7 @@ public function testConditionalAuthenticationAddsAuthenticationWhenMatched(): vo public function testConditionalAuthenticationDoesNotAddAuthenticationWhenUnmatched(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useConditionalAuth() @@ -98,7 +96,7 @@ public function testConditionalAuthenticationDoesNotAddAuthenticationWhenUnmatch public function testChainedAuthenticationCanBeUsed(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useChainedAuth('X-Chain-Auth', 'chain') @@ -110,7 +108,7 @@ public function testChainedAuthenticationCanBeUsed(): void public function testConfiguredAuthenticationReplacesPreviousAuthentication(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useBearerAuth('secret') @@ -118,7 +116,7 @@ public function testConfiguredAuthenticationReplacesPreviousAuthentication(): vo ->raw() ->fetch(); - parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + $query = $this->queryFromLastRequest($client); $this->assertSame('', $client->getLastRequest()->getHeaderLine('Authorization')); $this->assertSame('key', $query['appid']); @@ -126,7 +124,7 @@ public function testConfiguredAuthenticationReplacesPreviousAuthentication(): vo public function testCustomAuthenticationCallbackCanBeUsed(): void { - $client = $this->client(); + $client = $this->mockClient(); (new JsonApi($client)) ->useCustomAuth('X-Custom-Auth', 'custom') @@ -135,12 +133,4 @@ public function testCustomAuthenticationCallbackCanBeUsed(): void $this->assertSame('custom', $client->getLastRequest()->getHeaderLine('X-Custom-Auth')); } - - private function client(): Client - { - $client = new Client(); - $client->addResponse(new Response(body: '{}')); - - return $client; - } } diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php index c160c27..6d1221e 100644 --- a/tests/Integration/CacheTest.php +++ b/tests/Integration/CacheTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Mock\Client; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\JsonApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; @@ -12,8 +11,7 @@ class CacheTest extends AbstractTestCase { public function testSdkUserCanConfigureCache(): void { - $client = new Client(); - $client->addResponse(new Response( + $client = $this->mockClient(new Response( headers: ['Cache-Control' => 'max-age=60'], body: '{"id":1}' )); diff --git a/tests/Integration/ErrorHandlingTest.php b/tests/Integration/ErrorHandlingTest.php index e6b01ef..e712585 100644 --- a/tests/Integration/ErrorHandlingTest.php +++ b/tests/Integration/ErrorHandlingTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Mock\Client; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\InvalidApiKeyException; use ProgrammatorDev\Api\Test\Fixture\JsonApi; @@ -13,8 +12,7 @@ class ErrorHandlingTest extends AbstractTestCase { public function testHttpErrorStatusDoesNotThrowByDefault(): void { - $client = new Client(); - $client->addResponse(new Response(status: 404, body: '{"message":"Missing user"}')); + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing user"}')); $response = (new JsonApi($client))->raw()->fetch(); @@ -24,8 +22,7 @@ public function testHttpErrorStatusDoesNotThrowByDefault(): void public function testConfiguredStatusErrorThrows(): void { - $client = new Client(); - $client->addResponse(new Response(status: 404, body: '{"message":"Missing user"}')); + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing user"}')); $this->expectException(NotFoundException::class); $this->expectExceptionMessage('Missing user'); @@ -38,8 +35,7 @@ public function testConfiguredStatusErrorThrows(): void public function testConfiguredStatusErrorCanMapDirectlyToThrowableClass(): void { - $client = new Client(); - $client->addResponse(new Response(status: 404, body: '{"message":"Missing user"}')); + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing user"}')); $this->expectException(NotFoundException::class); @@ -51,8 +47,7 @@ public function testConfiguredStatusErrorCanMapDirectlyToThrowableClass(): void public function testConfiguredStatusErrorCanMapMultipleStatuses(): void { - $client = new Client(); - $client->addResponse(new Response(status: 401, body: '{"message":"Invalid API key"}')); + $client = $this->mockClient(new Response(status: 401, body: '{"message":"Invalid API key"}')); $this->expectException(InvalidApiKeyException::class); @@ -64,8 +59,7 @@ public function testConfiguredStatusErrorCanMapMultipleStatuses(): void public function testConfiguredCustomErrorHandlerThrowsWhenMatched(): void { - $client = new Client(); - $client->addResponse(new Response(status: 401, body: '{"code":"invalid_api_key","message":"Invalid API key"}')); + $client = $this->mockClient(new Response(status: 401, body: '{"code":"invalid_api_key","message":"Invalid API key"}')); $this->expectException(InvalidApiKeyException::class); $this->expectExceptionMessage('Invalid API key'); @@ -78,8 +72,7 @@ public function testConfiguredCustomErrorHandlerThrowsWhenMatched(): void public function testConfiguredCustomErrorHandlerDoesNotThrowWhenUnmatched(): void { - $client = new Client(); - $client->addResponse(new Response(status: 401, body: '{"code":"rate_limited","message":"Too many requests"}')); + $client = $this->mockClient(new Response(status: 401, body: '{"code":"rate_limited","message":"Too many requests"}')); $response = (new JsonApi($client)) ->throwInvalidApiKeyErrors() diff --git a/tests/Integration/HookTest.php b/tests/Integration/HookTest.php index 70b6ba6..8b9d1e5 100644 --- a/tests/Integration/HookTest.php +++ b/tests/Integration/HookTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Mock\Client; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Context\RequestContext; use ProgrammatorDev\Api\Context\ResponseContext; @@ -14,7 +13,7 @@ class HookTest extends AbstractTestCase { public function testBeforeRequestHookCanReplaceRequest(): void { - $client = $this->client(); + $client = $this->mockClient(new Response(body: '{"ok":false}')); (new JsonApi($client)) ->beforeRequest(fn (RequestContext $context) => $context->request()->withHeader('X-Hook', 'before')) @@ -26,7 +25,7 @@ public function testBeforeRequestHookCanReplaceRequest(): void public function testAfterResponseHookCanReplaceResponse(): void { - $client = $this->client(); + $client = $this->mockClient(new Response(body: '{"ok":false}')); $response = (new JsonApi($client)) ->afterResponse(fn (ResponseContext $context) => new Response(status: 202, body: '{"ok":true}')) @@ -39,7 +38,7 @@ public function testAfterResponseHookCanReplaceResponse(): void public function testHookReturningNullLeavesObjectUnchanged(): void { - $client = $this->client(); + $client = $this->mockClient(new Response(body: '{"ok":false}')); $response = (new JsonApi($client)) ->beforeRequest(fn (RequestContext $context) => null) @@ -54,7 +53,7 @@ public function testHookReturningNullLeavesObjectUnchanged(): void public function testHooksRunByPriorityAndInsertionOrder(): void { - $client = $this->client(); + $client = $this->mockClient(new Response(body: '{"ok":false}')); (new JsonApi($client)) ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'low'), priority: 10) @@ -68,7 +67,7 @@ public function testHooksRunByPriorityAndInsertionOrder(): void public function testHooksCanReadSdkConfig(): void { - $client = $this->client(); + $client = $this->mockClient(new Response(body: '{"ok":false}')); $api = new JsonApi($client); $api->config(['tenant' => 'acme']); @@ -86,7 +85,7 @@ public function testBeforeRequestHookRejectsInvalidReturnValue(): void $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('Before request hooks must return a RequestInterface instance or null.'); - (new JsonApi($this->client())) + (new JsonApi($this->mockClient(new Response(body: '{"ok":false}')))) ->beforeRequest(fn (RequestContext $context) => 'invalid') ->raw() ->fetch(); @@ -97,17 +96,9 @@ public function testAfterResponseHookRejectsInvalidReturnValue(): void $this->expectException(UnexpectedValueException::class); $this->expectExceptionMessage('After response hooks must return a ResponseInterface instance or null.'); - (new JsonApi($this->client())) + (new JsonApi($this->mockClient(new Response(body: '{"ok":false}')))) ->afterResponse(fn (ResponseContext $context) => 'invalid') ->raw() ->fetch(); } - - private function client(): Client - { - $client = new Client(); - $client->addResponse(new Response(body: '{"ok":false}')); - - return $client; - } } diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php index 8b20335..54680b4 100644 --- a/tests/Integration/PluginTest.php +++ b/tests/Integration/PluginTest.php @@ -3,18 +3,17 @@ namespace ProgrammatorDev\Api\Test\Integration; use Http\Client\Common\Plugin; -use Http\Mock\Client; -use Http\Promise\Promise; use Nyholm\Psr7\Response; +use ProgrammatorDev\Api\Test\Fixture\AuthStatePlugin; +use ProgrammatorDev\Api\Test\Fixture\HeaderPlugin; use ProgrammatorDev\Api\Test\Fixture\JsonApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; -use Psr\Http\Message\RequestInterface; class PluginTest extends AbstractTestCase { public function testConfiguredPluginsAreAppliedByPriorityOrder(): void { - $client = $this->client(responses: 1); + $client = $this->mockClient(); (new JsonApi($client)) ->usePlugin($this->headerPlugin('low'), priority: 8) @@ -28,7 +27,7 @@ public function testConfiguredPluginsAreAppliedByPriorityOrder(): void public function testSdkUserCanConfigurePlugins(): void { - $client = $this->client(responses: 1); + $client = $this->mockClient(); $api = new JsonApi($client); $api->setup()->plugins()->add($this->headerPlugin('user'), priority: 20); @@ -39,7 +38,7 @@ public function testSdkUserCanConfigurePlugins(): void public function testConfiguredPluginsWithSamePriorityAreAppliedInInsertionOrder(): void { - $client = $this->client(responses: 1); + $client = $this->mockClient(); (new JsonApi($client)) ->usePlugin($this->headerPlugin('first'), priority: 16) @@ -52,7 +51,7 @@ public function testConfiguredPluginsWithSamePriorityAreAppliedInInsertionOrder( public function testPluginPriorityCanTargetInternalAuthOrder(): void { - $client = $this->client(responses: 1); + $client = $this->mockClient(); (new JsonApi($client)) ->useBearerAuth('secret') @@ -69,7 +68,10 @@ public function testPluginPriorityCanTargetInternalAuthOrder(): void public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void { - $client = $this->client(responses: 2); + $client = $this->mockClient( + new Response(body: '{}'), + new Response(body: '{}') + ); $api = (new JsonApi($client))->usePlugin($this->headerPlugin('once'), priority: 16); $api->raw()->fetch(); @@ -81,38 +83,11 @@ public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void private function headerPlugin(string $value): Plugin { - return new class($value) implements Plugin { - public function __construct(private readonly string $value) {} - - public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise - { - return $next($request->withAddedHeader('X-Plugin-Order', $this->value)); - } - }; + return new HeaderPlugin('X-Plugin-Order', $value, append: true); } private function authStatePlugin(string $label): Plugin { - return new class($label) implements Plugin { - public function __construct(private readonly string $label) {} - - public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise - { - $state = $request->hasHeader('Authorization') ? 'present' : 'missing'; - - return $next($request->withAddedHeader('X-Auth-State', sprintf('%s:%s', $this->label, $state))); - } - }; - } - - private function client(int $responses): Client - { - $client = new Client(); - - for ($i = 0; $i < $responses; $i++) { - $client->addResponse(new Response(body: '{}')); - } - - return $client; + return new AuthStatePlugin($label); } } diff --git a/tests/Integration/ResponseDecodingTest.php b/tests/Integration/ResponseDecodingTest.php index aacd23c..c43dbac 100644 --- a/tests/Integration/ResponseDecodingTest.php +++ b/tests/Integration/ResponseDecodingTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Mock\Client; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\JsonApi; use ProgrammatorDev\Api\Test\Fixture\PlainApi; @@ -14,8 +13,7 @@ class ResponseDecodingTest extends AbstractTestCase { public function testResponseDataIsRawStringWhenJsonDecodingIsDisabled(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); $response = (new PlainApi($client))->raw()->fetch(); @@ -24,8 +22,7 @@ public function testResponseDataIsRawStringWhenJsonDecodingIsDisabled(): void public function testResponseDataIsDecodedWhenJsonDecodingIsEnabled(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); $response = (new JsonApi($client))->raw()->fetch(); @@ -34,8 +31,7 @@ public function testResponseDataIsDecodedWhenJsonDecodingIsEnabled(): void public function testEmptyJsonResponseBodyDecodesToNull(): void { - $client = new Client(); - $client->addResponse(new Response(body: '')); + $client = $this->mockClient(new Response(body: '')); $response = (new JsonApi($client))->raw()->fetch(); @@ -44,8 +40,7 @@ public function testEmptyJsonResponseBodyDecodesToNull(): void public function testInvalidJsonThrowsWhenJsonDecodingIsEnabled(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{invalid-json')); + $client = $this->mockClient(new Response(body: '{invalid-json')); $this->expectException(\JsonException::class); @@ -54,8 +49,7 @@ public function testInvalidJsonThrowsWhenJsonDecodingIsEnabled(): void public function testResponseDataIsDecodedWhenXmlDecodingIsEnabled(): void { - $client = new Client(); - $client->addResponse(new Response(body: '1John')); + $client = $this->mockClient(new Response(body: '1John')); $response = (new XmlApi($client))->raw()->fetch(); @@ -66,8 +60,7 @@ public function testResponseDataIsDecodedWhenXmlDecodingIsEnabled(): void public function testEmptyXmlResponseBodyDecodesToNull(): void { - $client = new Client(); - $client->addResponse(new Response(body: '')); + $client = $this->mockClient(new Response(body: '')); $response = (new XmlApi($client))->raw()->fetch(); @@ -76,8 +69,7 @@ public function testEmptyXmlResponseBodyDecodesToNull(): void public function testInvalidXmlThrowsWhenXmlDecodingIsEnabled(): void { - $client = new Client(); - $client->addResponse(new Response(body: 'mockClient(new Response(body: 'expectException(\RuntimeException::class); diff --git a/tests/Integration/SimpleApiProofTest.php b/tests/Integration/SimpleApiProofTest.php index ef4098e..487d4e9 100644 --- a/tests/Integration/SimpleApiProofTest.php +++ b/tests/Integration/SimpleApiProofTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Mock\Client; use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\Simple\SimpleApi; use ProgrammatorDev\Api\Test\Fixture\Simple\SimpleEntity; @@ -18,8 +17,7 @@ class SimpleApiProofTest extends AbstractTestCase public function testItemCanBeMappedToEntity(): void { - $client = new Client(); - $client->addResponse(new Response(body: self::ITEM_RESPONSE)); + $client = $this->mockClient(new Response(body: self::ITEM_RESPONSE)); $api = new SimpleApi('test-key', ['locale' => 'pt', 'version' => 'v2']); $api->setup()->client($client); @@ -32,7 +30,7 @@ public function testItemCanBeMappedToEntity(): void $this->assertSame('pt', $item->getLocale()); $this->assertSame('v2', $item->getVersion()); - parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + $query = $this->queryFromLastRequest($client); $this->assertSame('/items/1', $client->getLastRequest()->getUri()->getPath()); $this->assertSame('test-key', $query['api_key']); @@ -42,8 +40,7 @@ public function testItemCanBeMappedToEntity(): void public function testItemCanBeMappedToEnvelope(): void { - $client = new Client(); - $client->addResponse(new Response(status: 202, body: self::ITEM_RESPONSE)); + $client = $this->mockClient(new Response(status: 202, body: self::ITEM_RESPONSE)); $api = new SimpleApi('test-key'); $api->setup()->client($client); @@ -60,8 +57,7 @@ public function testItemCanBeMappedToEnvelope(): void public function testItemsCanBeMappedToCollection(): void { - $client = new Client(); - $client->addResponse(new Response(body: '{ + $client = $this->mockClient(new Response(body: '{ "data": [ {"id": 1, "name": "First item"}, {"id": 2, "name": "Second item"} @@ -78,7 +74,7 @@ public function testItemsCanBeMappedToCollection(): void $this->assertSame('Second item', $items[1]->getName()); $this->assertSame('pt', $items[0]->getLocale()); - parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + $query = $this->queryFromLastRequest($client); $this->assertSame('/items', $client->getLastRequest()->getUri()->getPath()); $this->assertSame('test-key', $query['api_key']); diff --git a/tests/Support/AbstractTestCase.php b/tests/Support/AbstractTestCase.php index 1a7975f..a23babe 100644 --- a/tests/Support/AbstractTestCase.php +++ b/tests/Support/AbstractTestCase.php @@ -2,8 +2,28 @@ namespace ProgrammatorDev\Api\Test\Support; +use Http\Mock\Client; +use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; abstract class AbstractTestCase extends TestCase { + protected function mockClient(ResponseInterface ...$responses): Client + { + $client = new Client(); + + foreach ($responses ?: [new Response(body: '{}')] as $response) { + $client->addResponse($response); + } + + return $client; + } + + protected function queryFromLastRequest(Client $client): array + { + parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + + return $query; + } } diff --git a/tests/Unit/Builder/CacheBuilderTest.php b/tests/Unit/Builder/CacheBuilderTest.php index 2b8b7d6..80136ea 100644 --- a/tests/Unit/Builder/CacheBuilderTest.php +++ b/tests/Unit/Builder/CacheBuilderTest.php @@ -8,7 +8,7 @@ class CacheBuilderTest extends AbstractTestCase { - public function testDefaults() + public function testCacheBuilderUsesDefaults(): void { $pool = $this->createMock(CacheItemPoolInterface::class); @@ -20,7 +20,7 @@ public function testDefaults() $this->assertSame(['max-age'], $cacheBuilder->getResponseCacheDirectives()); } - public function testDependencyInjection() + public function testCacheBuilderAcceptsConstructorValues(): void { $pool = $this->createMock(CacheItemPoolInterface::class); $defaultTtl = 600; @@ -29,13 +29,13 @@ public function testDependencyInjection() $cacheBuilder = new CacheBuilder($pool, $defaultTtl, $methods, $responseCacheDirectives); - $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); + $this->assertSame($pool, $cacheBuilder->getPool()); $this->assertSame($defaultTtl, $cacheBuilder->getDefaultTtl()); $this->assertSame($methods, $cacheBuilder->getMethods()); $this->assertSame($responseCacheDirectives, $cacheBuilder->getResponseCacheDirectives()); } - public function testFluentMethods() + public function testCacheBuilderCanBeConfiguredFluently(): void { $pool = $this->createMock(CacheItemPoolInterface::class); $defaultTtl = 600; @@ -48,7 +48,7 @@ public function testFluentMethods() ->methods($methods) ->responseCacheDirectives($responseCacheDirectives); - $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); + $this->assertSame($pool, $cacheBuilder->getPool()); $this->assertSame($defaultTtl, $cacheBuilder->getDefaultTtl()); $this->assertSame($methods, $cacheBuilder->getMethods()); $this->assertSame($responseCacheDirectives, $cacheBuilder->getResponseCacheDirectives()); diff --git a/tests/Unit/Builder/ClientBuilderTest.php b/tests/Unit/Builder/ClientBuilderTest.php index 6a5bfc2..940652b 100644 --- a/tests/Unit/Builder/ClientBuilderTest.php +++ b/tests/Unit/Builder/ClientBuilderTest.php @@ -10,7 +10,7 @@ class ClientBuilderTest extends AbstractTestCase { - public function testDefaults() + public function testClientBuilderUsesDiscoveredDefaults(): void { $clientBuilder = new ClientBuilder(); @@ -19,7 +19,7 @@ public function testDefaults() $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); } - public function testDependencyInjection() + public function testClientBuilderAcceptsConstructorValues(): void { $client = $this->createMock(ClientInterface::class); $requestFactory = $this->createMock(RequestFactoryInterface::class); @@ -28,11 +28,11 @@ public function testDependencyInjection() $clientBuilder = new ClientBuilder($client, $requestFactory, $streamFactory); $this->assertInstanceOf(ClientInterface::class, $clientBuilder->getClient()); - $this->assertInstanceOf(RequestFactoryInterface::class, $clientBuilder->getRequestFactory()); - $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); + $this->assertSame($requestFactory, $clientBuilder->getRequestFactory()); + $this->assertSame($streamFactory, $clientBuilder->getStreamFactory()); } - public function testFluentMethods() + public function testClientBuilderCanBeConfiguredFluently(): void { $client = $this->createMock(ClientInterface::class); $requestFactory = $this->createMock(RequestFactoryInterface::class); @@ -44,8 +44,7 @@ public function testFluentMethods() ->streamFactory($streamFactory); $this->assertInstanceOf(ClientInterface::class, $clientBuilder->getClient()); - $this->assertInstanceOf(RequestFactoryInterface::class, $clientBuilder->getRequestFactory()); - $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); + $this->assertSame($requestFactory, $clientBuilder->getRequestFactory()); + $this->assertSame($streamFactory, $clientBuilder->getStreamFactory()); } - } diff --git a/tests/Unit/Builder/LoggerBuilderTest.php b/tests/Unit/Builder/LoggerBuilderTest.php index 73c89b5..018f7db 100644 --- a/tests/Unit/Builder/LoggerBuilderTest.php +++ b/tests/Unit/Builder/LoggerBuilderTest.php @@ -9,28 +9,28 @@ class LoggerBuilderTest extends AbstractTestCase { - public function testDefaults() + public function testLoggerBuilderUsesDefaults(): void { $logger = $this->createMock(LoggerInterface::class); $loggerBuilder = new LoggerBuilder($logger); - $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); + $this->assertSame($logger, $loggerBuilder->getLogger()); $this->assertInstanceOf(Formatter\SimpleFormatter::class, $loggerBuilder->getFormatter()); } - public function testDependencyInjection() + public function testLoggerBuilderAcceptsConstructorValues(): void { $logger = $this->createMock(LoggerInterface::class); $formatter = $this->createMock(Formatter::class); $loggerBuilder = new LoggerBuilder($logger, $formatter); - $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); - $this->assertInstanceOf(Formatter::class, $loggerBuilder->getFormatter()); + $this->assertSame($logger, $loggerBuilder->getLogger()); + $this->assertSame($formatter, $loggerBuilder->getFormatter()); } - public function testFluentMethods() + public function testLoggerBuilderCanBeConfiguredFluently(): void { $logger = $this->createMock(LoggerInterface::class); $formatter = $this->createMock(Formatter::class); @@ -39,7 +39,7 @@ public function testFluentMethods() ->logger($logger) ->formatter($formatter); - $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); - $this->assertInstanceOf(Formatter::class, $loggerBuilder->getFormatter()); + $this->assertSame($logger, $loggerBuilder->getLogger()); + $this->assertSame($formatter, $loggerBuilder->getFormatter()); } } From 484c2f0a54121c6f1a976fa3fd5b87153d636892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 17:36:06 +0100 Subject: [PATCH 50/88] test(v3): remove no-op error builder assertions --- tests/Unit/Builder/ErrorBuilderTest.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Builder/ErrorBuilderTest.php b/tests/Unit/Builder/ErrorBuilderTest.php index 397a1f3..32b2feb 100644 --- a/tests/Unit/Builder/ErrorBuilderTest.php +++ b/tests/Unit/Builder/ErrorBuilderTest.php @@ -16,10 +16,10 @@ public function testUnmatchedStatusDoesNotThrow(): void { $builder = new ErrorBuilder(); + $this->expectNotToPerformAssertions(); + $builder->status(404, fn(): \Throwable => new \RuntimeException('Not found')); $builder->throwIfMatched($this->context(statusCode: 200)); - - $this->assertTrue(true); } public function testMatchedStatusThrowsConfiguredThrowable(): void @@ -89,11 +89,12 @@ public function testCustomHandlerThrowsWhenMatched(): void public function testCustomHandlerDoesNotThrowWhenNotMatched(): void { $builder = new ErrorBuilder(); + + $this->expectNotToPerformAssertions(); + $builder->when(fn(): ?\Throwable => null); $builder->throwIfMatched($this->context(statusCode: 200)); - - $this->assertTrue(true); } public function testCustomHandlerMustReturnThrowableOrNull(): void @@ -115,7 +116,7 @@ private function context(int $statusCode, array $data = []): ErrorContext $context = new Context(new Config(['timezone' => 'UTC'])); return new ErrorContext( - response: new Response($data ?? [], new PsrResponse(status: $statusCode), $context), + response: new Response($data, new PsrResponse(status: $statusCode), $context), context: $context ); } From 82d1be37b5a2f116d70add4b4a92380c8e80a5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 6 Jun 2026 18:03:24 +0100 Subject: [PATCH 51/88] refactor(context): align error api context accessor --- docs/responses.md | 2 +- src/Context/ErrorContext.php | 2 +- tests/Unit/Builder/ErrorBuilderTest.php | 2 +- tests/Unit/Context/ErrorContextTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/responses.md b/docs/responses.md index d49f1cf..0a4a32f 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -133,5 +133,5 @@ $this->errors()->when(function (ErrorContext $context): ?Throwable { It exposes: - `response(): Response` -- `context(): Context` +- `apiContext(): Context` - `statusCode(): int` diff --git a/src/Context/ErrorContext.php b/src/Context/ErrorContext.php index 37f147b..2f83e34 100644 --- a/src/Context/ErrorContext.php +++ b/src/Context/ErrorContext.php @@ -16,7 +16,7 @@ public function response(): Response return $this->response; } - public function context(): Context + public function apiContext(): Context { return $this->context; } diff --git a/tests/Unit/Builder/ErrorBuilderTest.php b/tests/Unit/Builder/ErrorBuilderTest.php index 32b2feb..f715dfd 100644 --- a/tests/Unit/Builder/ErrorBuilderTest.php +++ b/tests/Unit/Builder/ErrorBuilderTest.php @@ -26,7 +26,7 @@ public function testMatchedStatusThrowsConfiguredThrowable(): void { $builder = new ErrorBuilder(); $builder->status(404, fn(ErrorContext $context): \Throwable => new \RuntimeException( - sprintf('Status %d in %s', $context->statusCode(), $context->context()->config()->get('timezone')) + sprintf('Status %d in %s', $context->statusCode(), $context->apiContext()->config()->get('timezone')) )); $this->expectException(\RuntimeException::class); diff --git a/tests/Unit/Context/ErrorContextTest.php b/tests/Unit/Context/ErrorContextTest.php index 6cd4a4c..a75b134 100644 --- a/tests/Unit/Context/ErrorContextTest.php +++ b/tests/Unit/Context/ErrorContextTest.php @@ -18,7 +18,7 @@ public function testContextReturnsResponseContextAndStatusCode(): void $errorContext = new ErrorContext($response, $context); $this->assertSame($response, $errorContext->response()); - $this->assertSame($context, $errorContext->context()); + $this->assertSame($context, $errorContext->apiContext()); $this->assertSame(404, $errorContext->statusCode()); } } From 2a8e7c00074e21d7b64b37c2d8bf1eff79cb8b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 16:41:08 +0100 Subject: [PATCH 52/88] docs: organize v3 guide pages --- README.md | 850 +----------------- docs/00-index.md | 48 + ...tting-started.md => 01-getting-started.md} | 5 +- ...sign-approach.md => 02-design-approach.md} | 5 + docs/{api.md => 03-api.md} | 17 +- ...-authoring.md => 04-resource-authoring.md} | 5 + docs/{resources.md => 05-resources.md} | 5 + docs/{responses.md => 06-responses.md} | 5 + ...authentication.md => 07-authentication.md} | 5 + docs/{http-client.md => 08-http-client.md} | 7 +- docs/{cache.md => 09-cache.md} | 7 +- docs/{logging.md => 10-logging.md} | 7 +- docs/{plugins.md => 11-plugins.md} | 7 +- docs/{hooks.md => 12-hooks.md} | 5 + docs/13-api-reference.md | 17 + docs/api-reference.md | 11 - docs/index.md | 41 - 17 files changed, 162 insertions(+), 885 deletions(-) create mode 100644 docs/00-index.md rename docs/{getting-started.md => 01-getting-started.md} (97%) rename docs/{design-approach.md => 02-design-approach.md} (95%) rename docs/{api.md => 03-api.md} (92%) rename docs/{resource-authoring.md => 04-resource-authoring.md} (98%) rename docs/{resources.md => 05-resources.md} (95%) rename docs/{responses.md => 06-responses.md} (97%) rename docs/{authentication.md => 07-authentication.md} (96%) rename docs/{http-client.md => 08-http-client.md} (92%) rename docs/{cache.md => 09-cache.md} (88%) rename docs/{logging.md => 10-logging.md} (89%) rename docs/{plugins.md => 11-plugins.md} (92%) rename docs/{hooks.md => 12-hooks.md} (96%) create mode 100644 docs/13-api-reference.md delete mode 100644 docs/api-reference.md delete mode 100644 docs/index.md diff --git a/README.md b/README.md index a6cf77b..3ae06b6 100644 --- a/README.md +++ b/README.md @@ -1,836 +1,44 @@ -# PHP API SDK +# Documentation -[![Latest Version](https://img.shields.io/github/release/programmatordev/php-api-sdk.svg?style=flat-square)](https://github.com/programmatordev/php-api-sdk/releases) -[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) -[![Tests](https://github.com/programmatordev/php-api-sdk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/php-api-sdk/actions/workflows/ci.yml?query=branch%3Amain) +These docs describe how to create API SDKs with this package. -A library for creating SDKs in PHP with support for: -- [PSR-18 HTTP clients](https://www.php-fig.org/psr/psr-18); -- [PSR-17 HTTP factories](https://www.php-fig.org/psr/psr-17); -- [PSR-6 caches](https://www.php-fig.org/psr/psr-6); -- [PSR-3 logs](https://www.php-fig.org/psr/psr-3); -- Authentication; -- Event listeners; -- ...and more. +This package is built for two developer audiences: -All methods are public for full hackability 🔥. +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. + +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. + +The practical guides below show how to build resources, map responses, and configure the HTTP pipeline. Read [Design Approach](docs/02-design-approach.md) for more about the reasoning behind the API shape. ## Requirements -- PHP 8.1 or higher. +- PHP `>=8.1` +- A PSR-18 HTTP client implementation +- PSR-17 request and stream factory implementations -## Installation +The package can discover compatible HTTP clients and factories through PHP-HTTP discovery when implementations are installed. -Install the library via [Composer](https://getcomposer.org/): +## Installation ```bash composer require programmatordev/php-api-sdk ``` -## Basic Usage - -Just extend your API library with the `Api` class and have fun coding: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct() - { - parent::__construct(); - - // recommended config - $this->setBaseUrl('https://api.example.com/v1'); - } - - public function getPosts(int $page = 1): string - { - // GET https://api.example.com/v1/posts?page=1 - return $this->request( - method: 'GET', - path: '/posts', - query: [ - 'page' => $page - ] - ); - } -} -``` - -## Documentation - -- [Base URL](#base-url) -- [Requests](#requests) -- [Query defaults](#query-defaults) -- [Header defaults](#header-defaults) -- [Authentication](#authentication) -- [Event listeners](#event-listeners) -- [HTTP client (PSR-18) and HTTP factories (PSR-17)](#http-client-psr-18-and-http-factories-psr-17) -- [Cache (PSR-6)](#cache-psr-6) -- [Logger (PSR-3)](#logger-psr-3) - -### Base URL - -Getter and setter for the base URL. -Base URL is the common part of the API URL and will be used in all requests. - -```php -$this->setBaseUrl(?string $baseUrl): self -``` - -```php -$this->getBaseUrl(): ?string -``` - -### Requests - -- [`request`](#request) -- [`buildPath`](#buildpath) - -#### `request` - -This method is used to send a request to an API. - -```php -use Psr\Http\Message\StreamInterface; - -$this->request( - string $method, - string $path, - array $query = [], - array $headers = [], - StreamInterface|string $body = null -): mixed -``` - -> [!NOTE] -> A `ClientException` will be thrown if there is an error while processing the request. - -For example, if you wanted to get a list of users with pagination: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct() - { - parent::__construct(); - - // recommended config - $this->setBaseUrl('https://api.example.com/v1'); - } - - public function getUsers(int $page = 1, int $perPage = 20): string - { - // GET https://api.example.com/v1/users?page=1&limit=20 - return $this->request( - method: 'GET', - path: '/users', - query: [ - 'page' => $page, - 'limit' => $perPage - ] - ); - } -} -``` - -By default, this method will return a `string` as it will be the response of the request as is. -If you want to change how the response is handled in all requests (for example, decode a JSON string into an array), -check the [`addResponseContentsListener`](#addresponsecontentslistener) method in the [Event Listeners](#event-listeners) section. - -> [!NOTE] -> If the `path` set is a full URL, it will be used as the request URL even if a `baseUrl` is set. - -#### `buildPath` - -The purpose of this method is to have an easy way to build a properly formatted path depending on the inputs or parameters you might have. - -```php -$this->buildPath(string $path, array $parameters): string; -``` - -For example, if you want to build a path that has a dynamic id: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct() - { - parent::__construct(); - - // recommended config - $this->setBaseUrl('https://api.example.com/v1'); - } - - public function getPostComments(int $postId): string - { - // GET https://api.example.com/v1/posts/1/comments - return $this->request( - method: 'GET', - path: $this->buildPath('/posts/{postId}/comments', [ - 'postId' => $postId - ]) - ); - } -} -``` - -### Query Defaults - -These methods are used for handling default query parameters. -Default query parameters are applied to every API request. - -```php -$this->addQueryDefault(string $name, mixed $value): self -``` - -```php -$this->getQueryDefault(string $name): mixed -``` - -```php -$this->removeQueryDefault(string $name): self -``` - -For example, if you want to add a language query parameter in all requests: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct(string $language = 'en') - { - // ... - - $this->addQueryDefault('lang', $language); - } - - public function getPosts(): string - { - // GET https://api.example.com/v1/posts?lang=en - return $this->request( - method: 'GET', - path: '/posts' - ); - } - - public function getCategories(): string - { - // a query parameter with the same name, passed in the request method, will overwrite a query default - // GET https://api.example.com/v1/categories?lang=pt - return $this->request( - method: 'GET', - path: '/categories', - query: [ - 'lang' => 'pt' - ] - ); - } -} -``` - -> [!NOTE] -> A `query` parameter with the same name, passed in the `request` method, will overwrite a query default. -> Check the `getCategories` method in the example above. - -### Header Defaults - -These methods are used for handling default headers. -Default headers are applied to every API request. - -```php -$this->addHeaderDefault(string $name, mixed $value): self -``` - -```php -$this->getHeaderDefault(string $name): mixed -``` - -```php -$this->removeHeaderDefault(string $name): self -``` - -For example, if you want to add a language header value in all requests: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct(string $language = 'en') - { - // ... - - $this->addHeaderDefault('X-LANGUAGE', $language); - } - - public function getPosts(): string - { - // GET https://api.example.com/v1/posts with an 'X-LANGUAGE' => 'en' header value - return $this->request( - method: 'GET', - path: '/posts' - ); - } - - public function getCategories(): string - { - // a header with the same name, passed in the request method, will overwrite a header default - // GET https://api.example.com/v1/categories with an 'X-LANGUAGE' => 'pt' header value - return $this->request( - method: 'GET', - path: '/categories', - headers: [ - 'X-LANGUAGE' => 'pt' - ] - ); - } -} -``` - -> [!NOTE] -> A header with the same name, passed in the `request` method, will overwrite a header default. -> Check the `getCategories` method in the example above. - -### Authentication - -Getter and setter for API authentication. -Uses the [authentication component](https://docs.php-http.org/en/latest/message/authentication.html) from [PHP HTTP](https://docs.php-http.org/en/latest/index.html). - -```php -use Http\Message\Authentication; - -$this->setAuthentication(?Authentication $authentication): self; -``` - -```php -use Http\Message\Authentication; - -$this->getAuthentication(): ?Authentication; -``` - -Check all available authentication methods in the [PHP HTTP documentation](https://docs.php-http.org/en/latest/message/authentication.html#authentication-methods). - -You can also [implement your own](https://docs.php-http.org/en/latest/message/authentication.html#implement-your-own) authentication method. - -For example, if you have an API authenticated with a query parameter: - -```php -use ProgrammatorDev\Api\Api; -use Http\Message\Authentication\QueryParam; - -class YourApi extends Api -{ - public function __construct(string $applicationKey) - { - // ... - - $this->setAuthentication( - new QueryParam([ - 'api_token' => $applicationKey - ]) - ); - } - - public function getPosts(): string - { - // GET https://api.example.com/v1/posts?api_token=cd982h3diwh98dd23d32j - return $this->request( - method: 'GET', - path: '/posts' - ); - } -} -``` - -### Event Listeners - -- [`addPreRequestListener`](#addprerequestlistener) -- [`addPostRequestListener`](#addpostrequestlistener) -- [`addResponseContentsListener`](#addresponsecontentslistener) -- [Event Priority](#event-priority) -- [Event Propagation](#event-propagation) - -#### `addPreRequestListener` - -The `addPreRequestListener` method is used to add a function called before a request has been made. -This event listener will be applied to every API request. - -```php -$this->addPreRequestListener(callable $listener, int $priority = 0): self; -``` - -For example: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\PreRequestEvent; - -class YourApi extends Api -{ - public function __construct() - { - // a PreRequestEvent is passed as an argument - $this->addPreRequestListener(function(PreRequestEvent $event) { - $request = $event->getRequest(); - - if ($request->getMethod() === 'POST') { - // do something for all POST requests - // ... - } - }); - } - - // ... -} -``` - -Available event methods: - -```php -$this->addPreRequestListener(function(PreRequestEvent $event) { - // get request data - $request = $event->getRequest(); - // ... - // set request data - $event->setRequest($request); -}); -``` - -#### `addPostRequestListener` - -The `addPostRequestListener` method is used to add a function called after a request has been made. -This function can be used to inspect the request and response data that was sent to, and received from, the API. -This event listener will be applied to every API request. - -```php -$this->addPostRequestListener(callable $listener, int $priority = 0): self; -``` - -For example, you can use this event listener to handle API errors: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\PostRequestEvent; - -class YourApi extends Api -{ - public function __construct() - { - // a PostRequestEvent is passed as an argument - $this->addPostRequestListener(function(PostRequestEvent $event) { - $response = $event->getResponse(); - $statusCode = $response->getStatusCode(); - - // if there was a response with an error status code - if ($statusCode >= 400) { - // throw an exception - match ($statusCode) { - 400 => throw new BadRequestException(), - 404 => throw new NotFoundException(), - default => throw new UnexpectedErrorException() - }; - } - }); - } - - // ... -} -``` - -Available event methods: - -```php -$this->addPostRequestListener(function(PostRequestEvent $event) { - // get request data - $request = $event->getRequest(); - // get response data - $response = $event->getResponse(); - // ... - // set response data - $event->setResponse($response); -}); -``` - -#### `addResponseContentsListener` - -The `addResponseContentsListener` method is used to manipulate the response received from the API. -This event listener will be applied to every API request. - -```php -$this->addResponseContentsListener(callable $listener, int $priority = 0): self; -``` - -For example, if the API responses are JSON strings, you can use this event listener to decode them into arrays: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; - -class YourApi extends Api -{ - public function __construct() - { - // a ResponseContentsEvent is passed as an argument - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // get response contents and decode json string into an array - $contents = $event->getContents(); - $contents = json_decode($contents, true); - - // set handled contents - $event->setContents($contents); - }); - } - - public function getPosts(): array - { - // will return an array - return $this->request( - method: 'GET', - path: '/posts' - ); - } -} -``` - -Available event methods: - -```php -$this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // get response body contents data - $contents = $event->getContents(); - // ... - // set contents - $event->setContents($contents); -}); -``` - -#### Event Priority - -It is possible to add multiple listeners for the same event and set the order in which they will be executed. -By default, they will be executed in the same order as they are added, but you can set a `priority` to control that order. -Event listeners are then executed from the highest priority to the lowest: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; - -class YourApi extends Api -{ - public function __construct() - { - // two event listeners are added, - // but the second is executed first (higher priority) even though it was added after - - // executed last (lower priority) - $this->addResponseContentsListener( - listener: function(ResponseContentsEvent $event) { ... }, - priority: 0 - ); - - // executed first (higher priority) - $this->addResponseContentsListener( - listener: function(ResponseContentsEvent $event) { ... }, - priority: 10 - ); - } -} -``` - -#### Event Propagation - -In some cases, you may want to stop the event flow and prevent listeners from being called. -For that, you can use the `stopPropagation()` method: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; - -class YourApi extends Api -{ - public function __construct() - { - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // stop propagation so future listeners of this event will not be called - $event->stopPropagation(); - }); - - // this listener will not be called - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // ... - }); - } -} -``` - -### HTTP Client (PSR-18) and HTTP Factories (PSR-17) - -- [HTTP client and HTTP factory adapters](#http-client-and-http-factory-adapters) -- [Plugin system](#plugin-system) - -#### HTTP Client and HTTP Factory Adapters - -By default, this library makes use of the [HTTPlug's Discovery](https://github.com/php-http/discovery) library. -This means that it will automatically find and install a well-known PSR-18 client and PSR-17 factory implementation for you -(if they were not found on your project): -- [PSR-18 compatible implementations](https://packagist.org/providers/psr/http-client-implementation) -- [PSR-17 compatible implementations](https://packagist.org/providers/psr/http-factory-implementation) - -```php -use ProgrammatorDev\Api\Builder\ClientBuilder; - -new ClientBuilder( - // a PSR-18 client - ?ClientInterface $client = null, - // a PSR-17 request factory - ?RequestFactoryInterface $requestFactory = null, - // a PSR-17 stream factory - ?StreamFactoryInterface $streamFactory = null -); -``` - -```php -use ProgrammatorDev\Api\Builder\ClientBuilder; - -$this->setClientBuilder(ClientBuilder $clientBuilder): self; -``` - -```php -use ProgrammatorDev\Api\Builder\ClientBuilder; - -$this->getClientBuilder(): ClientBuilder; -``` - -If you don't want to rely on the discovery of implementations, you can set the ones you want: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\ClientBuilder; -use Symfony\Component\HttpClient\Psr18Client; -use Nyholm\Psr7\Factory\Psr17Factory; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - $client = new Psr18Client(); - $requestFactory = $streamFactory = new Psr17Factory(); - - $this->setClientBuilder( - new ClientBuilder( - client: $client, - requestFactory: $requestFactory, - streamFactory: $streamFactory - ) - ); - } -} -``` - -#### Plugin System - -This library enables attaching plugins to the HTTP client. -A plugin modifies the behavior of the client by intercepting the request and response flow. - -Since plugin order matters, a plugin is added with a priority level and is executed in descending order from highest to lowest. - -Check all the [available plugins](https://docs.php-http.org/en/latest/plugins/index.html) or [create your own](https://docs.php-http.org/en/latest/plugins/build-your-own.html). - -```php -use Http\Client\Common\Plugin; - -$this->getClientBuilder()->addPlugin(Plugin $plugin, int $priority): self; -``` - -It is important to know that this library already uses various plugins with different priorities. -The following list has all the implemented plugins with the respective priority in descending order (remember that order matters): - -| Plugin | Priority | Note | -|--------------------------------------------------------------------------------------------|----------|-----------------------------------| -| [`ContentTypePlugin`](https://docs.php-http.org/en/latest/plugins/content-type.html) | 40 | | -| [`ContentLengthPlugin`](https://docs.php-http.org/en/latest/plugins/content-length.html) | 32 | | -| [`AuthenticationPlugin`](https://docs.php-http.org/en/latest/plugins/authentication.html) | 24 | only if authentication is enabled | -| [`CachePlugin`](https://docs.php-http.org/en/latest/plugins/cache.html) | 16 | only if cache is enabled | -| [`LoggerPlugin`](https://docs.php-http.org/en/latest/plugins/logger.html) | 8 | only if logger is enabled | - -> [!IMPORTANT] -> The plugin priority in the list above is reserved. -> This means that if you try to add any plugin with the same priority, it will be overwritten. - -For example, if you wanted the client to automatically attempt to re-send a request that failed -(due to unreliable connections and servers, for example), you can add the [RetryPlugin](https://docs.php-http.org/en/latest/plugins/retry.html): - -```php -use ProgrammatorDev\Api\Api; -use Http\Client\Common\Plugin\RetryPlugin; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - // if a request fails, it will retry at least 3 times - // the priority is 20 to execute before the cache plugin - // (check the above plugin order list for more information) - $this->getClientBuilder()->addPlugin( - plugin: new RetryPlugin(['retries' => 3]), - priority: 20 - ); - } -} -``` - -### Cache (PSR-6) - -This library allows configuring the cache layer of the client for making API requests. -It uses a standard PSR-6 implementation and provides methods to fine-tune how HTTP caching behaves: -- [PSR-6 compatible implementations](https://packagist.org/providers/psr/cache-implementation) - -```php -use ProgrammatorDev\Api\Builder\CacheBuilder; -use Psr\Cache\CacheItemPoolInterface; - -new CacheBuilder( - // a PSR-6 cache adapter - CacheItemPoolInterface $pool, - // default lifetime (in seconds) of cached items - ?int $ttl = 60, - // An array of HTTP methods for which caching should be applied - $methods = ['GET', 'HEAD'], - // An array of cache directives to be compared with the headers of the HTTP response to determine cacheability - $responseCacheDirectives = ['max-age'] -); -``` - -```php -use ProgrammatorDev\Api\Builder\CacheBuilder; - -$this->setCacheBuilder(CacheBuilder $cacheBuilder): self; -``` - -```php -use ProgrammatorDev\Api\Builder\CacheBuilder; - -$this->getCacheBuilder(): CacheBuilder; -``` - -For example, if you wanted to set a file-based cache adapter: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\CacheBuilder; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - $pool = new FilesystemAdapter(); - - // file-based cache adapter with a 1-hour default cache lifetime - $this->setCacheBuilder( - new CacheBuilder( - pool: $pool, - ttl: 3600 - ) - ); - } - - public function getPosts(): string - { - // you can change the lifetime (and all other parameters) - // for this specific endpoint - $this->getCacheBuilder()->setTtl(600); - - return $this->request( - method: 'GET', - path: '/posts' - ); - } -} -``` - -### Logger (PSR-3) - -This library allows configuring a logger to save data for making API requests. -It uses a standard PSR-3 implementation and provides methods to fine-tune how logging behaves: -- [PSR-3 compatible implementations](https://packagist.org/providers/psr/log-implementation) - -```php -use ProgrammatorDev\Api\Builder\LoggerBuilder; -use Psr\Log\LoggerInterface; -use Http\Message\Formatter; -use Http\Message\Formatter\SimpleFormatter; - -new LoggerBuilder( - // a PSR-3 logger adapter - LoggerInterface $logger, - // determines how the log entries will be formatted when they are written by the logger - // if no formatter is provided, it will default to a SimpleFormatter instance - ?Formatter $formatter = null -); -``` - -```php -use ProgrammatorDev\Api\Builder\LoggerBuilder; - -$this->setLoggerBuilder(LoggerBuilder $loggerBuilder): self; -``` - -```php -use ProgrammatorDev\Api\Builder\LoggerBuilder; - -$this->getLoggerBuilder(): LoggerBuilder; -``` - -As an example, if you wanted to save logs into a file: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\LoggerBuilder; -use Monolog\Logger; -use Monolog\Handler\StreamHandler; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - $logger = new Logger('api'); - $logger->pushHandler(new StreamHandler('/logs/api.log')); - - $this->setLoggerBuilder( - new LoggerBuilder( - logger: $logger - ) - ); - } -} -``` - -## Libraries using PHP API SDK - -- [programmatordev/openweathermap-php-api](https://github.com/programmatordev/openweathermap-php-api) -- [programmatordev/sportmonksfootball-php-api](https://github.com/programmatordev/sportmonksfootball-php-api) - -## Contributing - -Any form of contribution to improve this library (including requests) will be welcome and appreciated. -Make sure to open a pull request or issue. +SDK packages should also require or suggest concrete PSR-18 and PSR-17 implementations suitable for their users. -## License +## Guides -This project is licensed under the MIT license. -Please see the [LICENSE](LICENSE) file distributed with this source code for further information regarding copyright and licensing. \ No newline at end of file +- [Getting Started](docs/01-getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. +- [Design Approach](docs/02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. +- [API](docs/03-api.md): SDK facade setup methods, configuration, and extension points. +- [Resource Authoring](docs/04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. +- [Resources](docs/05-resources.md): resource modifiers and protected 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. +- [API Reference](docs/13-api-reference.md): current v3 authoring methods and contracts. diff --git a/docs/00-index.md b/docs/00-index.md new file mode 100644 index 0000000..a189e22 --- /dev/null +++ b/docs/00-index.md @@ -0,0 +1,48 @@ +# Documentation + +These docs describe how to create API SDKs with this package. + +This package is built for two developer audiences: + +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. + +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. + +The practical guides below show how to build resources, map responses, and configure the HTTP pipeline. Read [Design Approach](02-design-approach.md) for more about the reasoning behind the API shape. + +## Requirements + +- PHP `>=8.1` +- A PSR-18 HTTP client implementation +- PSR-17 request and stream factory implementations + +The package can discover compatible HTTP clients and factories through PHP-HTTP discovery when implementations are installed. + +## Installation + +```bash +composer require programmatordev/php-api-sdk +``` + +SDK packages should also require or suggest concrete PSR-18 and PSR-17 implementations suitable for their users. + +## Guides + +- [Getting Started](01-getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. +- [Design Approach](02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. +- [API](03-api.md): SDK facade setup methods, configuration, and extension points. +- [Resource Authoring](04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. +- [Resources](05-resources.md): resource modifiers and protected 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. +- [API Reference](13-api-reference.md): current v3 authoring methods and contracts. + +## Navigation + +- Next: [Getting Started](01-getting-started.md) diff --git a/docs/getting-started.md b/docs/01-getting-started.md similarity index 97% rename from docs/getting-started.md rename to docs/01-getting-started.md index 254cbcf..43ee3ed 100644 --- a/docs/getting-started.md +++ b/docs/01-getting-started.md @@ -163,6 +163,7 @@ public function findWithMeta(int $id): UserResponse } ``` -## Next Steps +## Navigation -- Read [Resource Authoring](resource-authoring.md) for the full resource API. +- Previous: [Documentation](00-index.md) +- Next: [Design Approach](02-design-approach.md) diff --git a/docs/design-approach.md b/docs/02-design-approach.md similarity index 95% rename from docs/design-approach.md rename to docs/02-design-approach.md index 042550c..3235f2b 100644 --- a/docs/design-approach.md +++ b/docs/02-design-approach.md @@ -73,3 +73,8 @@ $response = $api->send('GET', '/new-endpoint/{id}', ['id' => 1]); ``` That request still uses configured base URL, defaults, auth, plugins, cache, hooks, response decoding, and error handling. + +## Navigation + +- Previous: [Getting Started](01-getting-started.md) +- Next: [API](03-api.md) diff --git a/docs/api.md b/docs/03-api.md similarity index 92% rename from docs/api.md rename to docs/03-api.md index 7ee04c2..dd4f96d 100644 --- a/docs/api.md +++ b/docs/03-api.md @@ -132,7 +132,7 @@ $this->auth() Authentication is applied automatically to outgoing requests. -See [Authentication](authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. +See [Authentication](07-authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. ## `hooks(): HookBuilder` @@ -147,7 +147,7 @@ $api->setup()->hooks()->beforeRequest($hook); Hooks are SDK-author extension points. They run around the raw HTTP request and response, before response decoding and error handling. -See [Hooks](hooks.md) for hook context objects, return values, and priority behavior. +See [Hooks](12-hooks.md) for hook context objects, return values, and priority behavior. ## `plugins(): PluginBuilder` @@ -161,7 +161,7 @@ $api->setup()->plugins()->add($plugin, priority: 16); Higher priority plugins run earlier. Same-priority plugins are preserved in insertion order. -See [Plugins](plugins.md) for internal plugin order and priority guidance. +See [Plugins](11-plugins.md) for internal plugin order and priority guidance. ## `cache(CacheItemPoolInterface $pool): CacheBuilder` @@ -176,7 +176,7 @@ $this $api->setup()->cache($pool)->defaultTtl(3600); ``` -See [Cache](cache.md) for cache options and plugin order. +See [Cache](09-cache.md) for cache options and plugin order. ## `client(ClientInterface $client): ClientBuilder` @@ -197,7 +197,7 @@ $this ->streamFactory($streamFactory); ``` -See [HTTP Client](http-client.md) for client and factory configuration. +See [HTTP Client](08-http-client.md) for client and factory configuration. ## `logger(LoggerInterface $logger): LoggerBuilder` @@ -211,7 +211,7 @@ $this $api->setup()->logger($logger); ``` -See [Logging](logging.md) for logger formatting and cache logging. +See [Logging](10-logging.md) for logger formatting and cache logging. ## `responses(): ResponseBuilder` @@ -327,3 +327,8 @@ Sets multiple option values. ```php $api->config()->merge(['timezone' => 'UTC', 'units' => 'metric']); ``` + +## Navigation + +- Previous: [Design Approach](02-design-approach.md) +- Next: [Resource Authoring](04-resource-authoring.md) diff --git a/docs/resource-authoring.md b/docs/04-resource-authoring.md similarity index 98% rename from docs/resource-authoring.md rename to docs/04-resource-authoring.md index f22aee2..2200ccb 100644 --- a/docs/resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -325,3 +325,8 @@ final class FixtureResource extends Resource } } ``` + +## Navigation + +- Previous: [API](03-api.md) +- Next: [Resources](05-resources.md) diff --git a/docs/resources.md b/docs/05-resources.md similarity index 95% rename from docs/resources.md rename to docs/05-resources.md index c5ca376..5208a3e 100644 --- a/docs/resources.md +++ b/docs/05-resources.md @@ -119,3 +119,8 @@ return $this ->send(Method::TRACE, '/debug') ->raw(); ``` + +## Navigation + +- Previous: [Resource Authoring](04-resource-authoring.md) +- Next: [Responses](06-responses.md) diff --git a/docs/responses.md b/docs/06-responses.md similarity index 97% rename from docs/responses.md rename to docs/06-responses.md index 0a4a32f..edf1102 100644 --- a/docs/responses.md +++ b/docs/06-responses.md @@ -135,3 +135,8 @@ It exposes: - `response(): Response` - `apiContext(): Context` - `statusCode(): int` + +## Navigation + +- Previous: [Resources](05-resources.md) +- Next: [Authentication](07-authentication.md) diff --git a/docs/authentication.md b/docs/07-authentication.md similarity index 96% rename from docs/authentication.md rename to docs/07-authentication.md index 1dd91f1..72f3102 100644 --- a/docs/authentication.md +++ b/docs/07-authentication.md @@ -91,3 +91,8 @@ $this->auth()->custom(function (RequestInterface $request): RequestInterface { The callback receives the outgoing PSR request and must return a PSR request. Returning anything else throws an `UnexpectedValueException`. + +## Navigation + +- Previous: [Responses](06-responses.md) +- Next: [HTTP Client](08-http-client.md) diff --git a/docs/http-client.md b/docs/08-http-client.md similarity index 92% rename from docs/http-client.md rename to docs/08-http-client.md index 84c9e64..81741ee 100644 --- a/docs/http-client.md +++ b/docs/08-http-client.md @@ -70,4 +70,9 @@ HTTPlug plugins are not configured on the client builder. They are configured th $api->setup()->plugins()->add($plugin, priority: 25); ``` -See [Plugins](plugins.md) for plugin order and priority guidance. +See [Plugins](11-plugins.md) for plugin order and priority guidance. + +## Navigation + +- Previous: [Authentication](07-authentication.md) +- Next: [Cache](09-cache.md) diff --git a/docs/cache.md b/docs/09-cache.md similarity index 88% rename from docs/cache.md rename to docs/09-cache.md index 43f8ea8..12ac24f 100644 --- a/docs/cache.md +++ b/docs/09-cache.md @@ -45,4 +45,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](logging.md) for cache log output. +See [Logging](10-logging.md) for cache log output. + +## Navigation + +- Previous: [HTTP Client](08-http-client.md) +- Next: [Logging](10-logging.md) diff --git a/docs/logging.md b/docs/10-logging.md similarity index 89% rename from docs/logging.md rename to docs/10-logging.md index 745a6a9..56193a1 100644 --- a/docs/logging.md +++ b/docs/10-logging.md @@ -51,4 +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](plugins.md) for the full internal plugin order. +See [Plugins](11-plugins.md) for the full internal plugin order. + +## Navigation + +- Previous: [Cache](09-cache.md) +- Next: [Plugins](11-plugins.md) diff --git a/docs/plugins.md b/docs/11-plugins.md similarity index 92% rename from docs/plugins.md rename to docs/11-plugins.md index 2ce84f9..97bcfb8 100644 --- a/docs/plugins.md +++ b/docs/11-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](http-client.md). Plugins are configured separately so middleware order remains explicit. +HTTP clients and PSR-17 factories are configured through [HTTP Client](08-http-client.md). Plugins are configured separately so middleware order remains explicit. SDK authors can configure plugins from the `Api` class: @@ -60,3 +60,8 @@ $this->plugins()->add($second, priority: 16); ``` The request reaches `$first` before `$second`. + +## Navigation + +- Previous: [Logging](10-logging.md) +- Next: [Hooks](12-hooks.md) diff --git a/docs/hooks.md b/docs/12-hooks.md similarity index 96% rename from docs/hooks.md rename to docs/12-hooks.md index b3e59e1..dabf5a2 100644 --- a/docs/hooks.md +++ b/docs/12-hooks.md @@ -96,3 +96,8 @@ create Response errors return Response ``` + +## Navigation + +- Previous: [Plugins](11-plugins.md) +- Next: [API Reference](13-api-reference.md) diff --git a/docs/13-api-reference.md b/docs/13-api-reference.md new file mode 100644 index 0000000..caa6a0c --- /dev/null +++ b/docs/13-api-reference.md @@ -0,0 +1,17 @@ +# API Reference + +This reference is split by where methods are available. + +- [API](03-api.md): `Api` setup methods and `Config`. +- [Resources](05-resources.md): resource modifiers and protected request helpers. +- [Responses](06-responses.md): `Response`, `EntityInterface`, `ResponseEnvelopeInterface`, and `Context`. +- [Authentication](07-authentication.md): `AuthBuilder` helpers and custom authentication. +- [HTTP Client](08-http-client.md): `ClientBuilder` helpers and PSR-18/PSR-17 configuration. +- [Cache](09-cache.md): `CacheBuilder` helpers and PSR-6 cache configuration. +- [Logging](10-logging.md): `LoggerBuilder` helpers and PSR-3 logging configuration. +- [Plugins](11-plugins.md): `PluginBuilder` helpers and internal plugin order. +- [Hooks](12-hooks.md): `HookBuilder`, request hooks, response hooks, and hook contexts. + +## Navigation + +- Previous: [Hooks](12-hooks.md) diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index 81208d3..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,11 +0,0 @@ -# API Reference - -This reference is split by where methods are available. - -- [API](api.md): `Api` setup methods and `Config`. -- [Authentication](authentication.md): `AuthBuilder` helpers and custom authentication. -- [Cache](cache.md): `CacheBuilder` helpers and PSR-6 cache configuration. -- [Plugins](plugins.md): `PluginBuilder` helpers and internal plugin order. -- [Hooks](hooks.md): `HookBuilder`, request hooks, response hooks, and hook contexts. -- [Resources](resources.md): resource modifiers and protected request helpers. -- [Responses](responses.md): `Response`, `EntityInterface`, `ResponseEnvelopeInterface`, and `Context`. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 7ccc4bf..0000000 --- a/docs/index.md +++ /dev/null @@ -1,41 +0,0 @@ -# Documentation - -These docs describe how to create API SDKs with this package. - -This package is built for two developer audiences: - -- SDK authors: developers creating concrete API SDKs with this library. -- SDK users: developers consuming those SDKs in applications. - -The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. - -The practical guides below show how to build resources, map responses, and configure the HTTP pipeline. Read [Design Approach](design-approach.md) for more about the reasoning behind the API shape. - -## Requirements - -- PHP `>=8.1` -- A PSR-18 HTTP client implementation -- PSR-17 request and stream factory implementations - -The package can discover compatible HTTP clients and factories through PHP-HTTP discovery when implementations are installed. - -## Installation - -```bash -composer require programmatordev/php-api-sdk -``` - -SDK packages should also require or suggest concrete PSR-18 and PSR-17 implementations suitable for their users. - -## Guides - -- [Getting Started](getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. -- [Authentication](authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. -- [HTTP Client](http-client.md): configure PSR-18 clients and PSR-17 factories. -- [Cache](cache.md): configure PSR-6 HTTP response caching. -- [Logging](logging.md): configure PSR-3 logging and HTTP/cache log output. -- [Plugins](plugins.md): configure HTTPlug middleware and priority ordering. -- [Hooks](hooks.md): run SDK-author callbacks around requests and responses. -- [Resource Authoring](resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. -- [API Reference](api-reference.md): current v3 authoring methods and contracts. -- [Design Approach](design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. From 807b814e9cf18707d8204469873943f05fadb0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 18:25:32 +0100 Subject: [PATCH 53/88] refactor(v3): introduce endpoint request builder --- README.md | 2 +- docs/00-index.md | 2 +- docs/01-getting-started.md | 29 +++-- docs/03-api.md | 2 +- docs/04-resource-authoring.md | 70 +++++++++--- docs/05-resources.md | 139 +++++++++++++----------- docs/06-responses.md | 3 + docs/13-api-reference.md | 2 +- docs/v3-architecture-plan.md | 29 ++--- src/Endpoint.php | 131 ++++++++++++++++++++++ src/Resource.php | 122 +-------------------- tests/Fixture/RawResource.php | 4 +- tests/Fixture/Simple/SimpleResource.php | 3 + tests/Fixture/UserResource.php | 60 +++++++--- tests/Integration/ResourceTest.php | 22 +++- 15 files changed, 378 insertions(+), 242 deletions(-) create mode 100644 src/Endpoint.php diff --git a/README.md b/README.md index 3ae06b6..d80a3aa 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Design Approach](docs/02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. - [API](docs/03-api.md): SDK facade setup methods, configuration, and extension points. - [Resource Authoring](docs/04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. -- [Resources](docs/05-resources.md): resource modifiers and protected request helpers. +- [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. diff --git a/docs/00-index.md b/docs/00-index.md index a189e22..16326e1 100644 --- a/docs/00-index.md +++ b/docs/00-index.md @@ -33,7 +33,7 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Design Approach](02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. - [API](03-api.md): SDK facade setup methods, configuration, and extension points. - [Resource Authoring](04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. -- [Resources](05-resources.md): resource modifiers and protected request helpers. +- [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. diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 43ee3ed..b36fb0c 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -71,7 +71,7 @@ final class User implements EntityInterface ## Create A Resource -Resources group endpoint methods. Use protected HTTP helpers and response mapping helpers to keep the endpoint code compact. +Resources group endpoint methods. Use `endpoint()` to start an endpoint request builder, then map the response. ```php use ProgrammatorDev\Api\Resource; @@ -81,6 +81,7 @@ final class UserResource extends Resource public function find(int $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } @@ -91,6 +92,7 @@ final class UserResource extends Resource public function all(): array { return $this + ->endpoint() ->get('/users') ->collection(User::class, key: 'data'); } @@ -98,6 +100,7 @@ final class UserResource extends Resource public function create(string $name): User { return $this + ->endpoint() ->json(['name' => $name]) ->post('/users') ->entity(User::class, key: 'data'); @@ -108,22 +111,31 @@ final class UserResource extends Resource Path parameters are passed as the second argument to the HTTP helper: ```php -$this->get('/users/{id}', ['id' => $id]); +$this->endpoint()->get('/users/{id}', ['id' => $id]); ``` Endpoint-specific query parameters can be passed as the third argument: ```php -$this->get('/users/{id}', ['id' => $id], ['locale' => 'pt']); +$this->endpoint()->get('/users/{id}', ['id' => $id], ['locale' => 'pt']); ``` -Reusable query and header options are fluent and immutable: +SDK authors decide how SDK users customize requests. Often a method argument is enough: ```php -$activeUsers = $api - ->users() - ->query('active', true) - ->all(); +final class UserResource extends Resource +{ + public function all(bool $active = true): array + { + return $this + ->endpoint() + ->query('active', $active) + ->get('/users') + ->collection(User::class, key: 'data'); + } +} + +$activeUsers = $api->users()->all(active: true); ``` ## Map Enveloped Responses @@ -158,6 +170,7 @@ Then return it from the resource: public function findWithMeta(int $id): UserResponse { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->envelope(UserResponse::class); } diff --git a/docs/03-api.md b/docs/03-api.md index dd4f96d..ed960ea 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -99,7 +99,7 @@ $this->defaultQueries(['api_key' => $apiKey, 'locale' => 'en']); Query merge order is: ```text -API defaults < resource options < endpoint-specific options +API defaults < endpoint options < endpoint method query argument ``` ## `defaultHeader(string $name, mixed $value): static` diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 2200ccb..b67b62f 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -10,6 +10,7 @@ final class UserResource extends Resource public function find(int $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } @@ -38,20 +39,22 @@ final class ExampleApi extends Api `Api::resource()` creates a fresh resource instance. Resource option modifiers are immutable, so fluent customizations do not leak into later calls. -## HTTP Methods +## Endpoint Requests -Resources expose protected HTTP helpers: +Use `endpoint()` inside resource methods to create the request builder: ```php -$this->get('/users'); -$this->post('/users'); -$this->put('/users/{id}', ['id' => $id]); -$this->patch('/users/{id}', ['id' => $id]); -$this->delete('/users/{id}', ['id' => $id]); -$this->head('/users'); -$this->options('/users'); -$this->connect('/users'); -$this->trace('/users'); +$endpoint = $this->endpoint(); + +$endpoint->get('/users'); +$endpoint->post('/users'); +$endpoint->put('/users/{id}', ['id' => $id]); +$endpoint->patch('/users/{id}', ['id' => $id]); +$endpoint->delete('/users/{id}', ['id' => $id]); +$endpoint->head('/users'); +$endpoint->options('/users'); +$endpoint->connect('/users'); +$endpoint->trace('/users'); ``` Each helper executes the request immediately and returns a `Response` wrapper. @@ -60,6 +63,7 @@ Endpoint-specific query parameters can be passed as the third argument: ```php return $this + ->endpoint() ->get('/users/{id}', ['id' => $id], ['locale' => 'pt']) ->entity(User::class); ``` @@ -68,10 +72,11 @@ Path parameters are encoded with `rawurlencode`. ## Query And Headers -Use resource modifiers for reusable request options: +Use endpoint modifiers for request-local options: ```php return $this + ->endpoint() ->query('active', true) ->header('X-Tenant', $tenant) ->get('/users') @@ -82,16 +87,39 @@ Multiple values can be set with `queries()` and `headers()`: ```php return $this + ->endpoint() ->queries(['active' => true, 'locale' => 'pt']) ->headers(['X-Tenant' => $tenant]) ->get('/users') ->collection(User::class, key: 'data'); ``` +SDK-user customization should be explicit in the resource method API. If a method argument is enough, prefer that over hidden resource state: + +```php +final class UserResource extends Resource +{ + public function all(bool $active = true): array + { + return $this + ->endpoint() + ->query('active', $active) + ->get('/users') + ->collection(User::class, key: 'data'); + } +} +``` + +SDK users call the public method chosen by the SDK author: + +```php +$users = $api->users()->all(active: true); +``` + Query merge order is: ```text -API defaults < resource options < endpoint-specific options +API defaults < endpoint options < endpoint method query argument ``` Null query values are omitted. Boolean and array query values use PHP's standard `http_build_query` behavior. @@ -102,6 +130,7 @@ Use explicit body helpers for structured request data: ```php return $this + ->endpoint() ->json(['name' => 'John']) ->post('/users') ->entity(User::class); @@ -111,6 +140,7 @@ return $this ```php return $this + ->endpoint() ->form(['name' => 'John Doe']) ->post('/users') ->entity(User::class); @@ -122,6 +152,7 @@ Use `body()` for raw string or PSR-7 stream bodies: ```php return $this + ->endpoint() ->body($stream) ->post('/uploads') ->raw(); @@ -135,6 +166,7 @@ Use `entity()` when the endpoint returns one typed object: ```php return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class); ``` @@ -161,6 +193,7 @@ Use the optional `key` argument when the object is nested inside an envelope: ```php return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class, key: 'data'); ``` @@ -169,6 +202,7 @@ Use `collection()` when the endpoint returns a list: ```php return $this + ->endpoint() ->get('/users') ->collection(User::class, key: 'data'); ``` @@ -300,11 +334,13 @@ Keep API-specific vocabulary out of the base package. Add it in SDK packages thr For example, an SDK can add includes without making the generic package know what an include is: ```php +use ProgrammatorDev\Api\Endpoint; + trait HasIncludes { - public function include(string ...$includes): static + protected function applyIncludes(Endpoint $endpoint, array $includes): Endpoint { - return $this->query('include', implode(';', $includes)); + return $endpoint->query('include', implode(';', $includes)); } } ``` @@ -316,10 +352,10 @@ final class FixtureResource extends Resource { use HasIncludes; - public function find(int $id): FixtureResponse + public function find(int $id, array $includes = []): FixtureResponse { return $this - ->include('participants', 'league') + ->applyIncludes($this->endpoint(), $includes) ->get('/fixtures/{id}', ['id' => $id]) ->envelope(FixtureResponse::class); } diff --git a/docs/05-resources.md b/docs/05-resources.md index 5208a3e..fa79e34 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -2,102 +2,131 @@ `Resource` is the base class for endpoint groups. -Public resource modifiers are available on resource instances. Protected request helpers are for SDK resource classes. +`Resource` keeps the SDK-user-facing domain surface small. SDK resource classes call `endpoint()` to start an endpoint request builder. -## `query(string $name, mixed $value): static` +## `endpoint(): Endpoint` -Public resource modifier. +Protected SDK-author helper. -Returns a cloned resource with one query option. +Returns a fresh endpoint request builder. ```php return $this - ->query('active', true) + ->endpoint() ->get('/users') - ->collection(User::class, key: 'data'); + ->raw(); ``` -Null query values are omitted. +## Endpoint Body Helpers -## `queries(array $query): static` +Endpoint body helpers are immutable and return a cloned endpoint builder. -Public resource modifier. +### `json(array $data): static` -Returns a cloned resource with multiple query options. +Sets a JSON request body and `Content-Type: application/json`. ```php -$this->queries(['active' => true, 'locale' => 'pt']); +return $this + ->endpoint() + ->json(['name' => 'John']) + ->post('/users') + ->entity(User::class); ``` -## `header(string $name, mixed $value): static` - -Public resource modifier. +### `form(array $data): static` -Returns a cloned resource with one header option. +Sets a form-encoded request body and `Content-Type: application/x-www-form-urlencoded`. ```php -$this->header('X-Tenant', $tenant); +return $this + ->endpoint() + ->form(['name' => 'John Doe']) + ->post('/users') + ->entity(User::class); ``` -## `headers(array $headers): static` +### `body(mixed $body): static` -Public resource modifier. - -Returns a cloned resource with multiple header options. +Sets a raw string, stream, or null request body. ```php -$this->headers(['X-Tenant' => $tenant]); +return $this + ->endpoint() + ->body($stream) + ->post('/uploads') + ->raw(); ``` -## `json(array $data): static` +Passing an array throws. Use `json()` or `form()` for array data. -Public resource modifier. +## Endpoint Query And Headers -Sets a JSON request body and `Content-Type: application/json`. +### `query(string $name, mixed $value): static` + +Sets one endpoint-local query option. ```php return $this - ->json(['name' => 'John']) - ->post('/users') - ->entity(User::class); + ->endpoint() + ->query('active', true) + ->get('/users') + ->collection(User::class, key: 'data'); ``` -## `form(array $data): static` - -Public resource modifier. +### `queries(array $query): static` -Sets a form-encoded request body and `Content-Type: application/x-www-form-urlencoded`. +Sets multiple endpoint-local query options. ```php -$this->form(['name' => 'John Doe']); +return $this + ->endpoint() + ->queries(['active' => true, 'locale' => 'pt']) + ->get('/users') + ->collection(User::class, key: 'data'); ``` -## `body(mixed $body): static` +### `header(string $name, mixed $value): static` -Public resource modifier. - -Sets a raw string, stream, or null request body. +Sets one endpoint-local header. ```php -$this->body($stream); +return $this + ->endpoint() + ->header('X-Upload-Type', 'avatar') + ->body($stream) + ->post('/uploads') + ->raw(); ``` -Passing an array throws. Use `json()` or `form()` for array data. +### `headers(array $headers): static` -## HTTP Helpers +Sets multiple endpoint-local headers. -Protected resource helpers execute the request immediately and return `Response`: +```php +return $this + ->endpoint() + ->headers(['X-Upload-Type' => 'avatar']) + ->body($stream) + ->post('/uploads') + ->raw(); +``` + +## Endpoint HTTP Methods + +Endpoint HTTP helpers execute the request immediately and return `Response`: ```php -$this->get('/users'); -$this->post('/users'); -$this->put('/users/{id}', ['id' => $id]); -$this->patch('/users/{id}', ['id' => $id]); -$this->delete('/users/{id}', ['id' => $id]); -$this->head('/users'); -$this->options('/users'); -$this->connect('/users'); -$this->trace('/users'); +$endpoint = $this->endpoint(); + +$endpoint->get('/users'); +$endpoint->post('/users'); +$endpoint->put('/users/{id}', ['id' => $id]); +$endpoint->patch('/users/{id}', ['id' => $id]); +$endpoint->delete('/users/{id}', ['id' => $id]); +$endpoint->head('/users'); +$endpoint->options('/users'); +$endpoint->connect('/users'); +$endpoint->trace('/users'); ``` All helpers accept: @@ -108,18 +137,6 @@ array $pathParams = [] array $query = [] ``` -## `send(string $method, string $path, array $pathParams = [], array $query = []): Response` - -Protected escape hatch for methods without a named helper. - -```php -use ProgrammatorDev\Api\Http\Method; - -return $this - ->send(Method::TRACE, '/debug') - ->raw(); -``` - ## Navigation - Previous: [Resource Authoring](04-resource-authoring.md) diff --git a/docs/06-responses.md b/docs/06-responses.md index edf1102..124fc05 100644 --- a/docs/06-responses.md +++ b/docs/06-responses.md @@ -36,6 +36,7 @@ Maps decoded response data to an entity class. ```php return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class, key: 'data'); ``` @@ -48,6 +49,7 @@ Maps list data to a plain array of entities. ```php return $this + ->endpoint() ->get('/users') ->collection(User::class, key: 'data'); ``` @@ -58,6 +60,7 @@ Maps the response to a custom envelope. ```php return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->envelope(UserResponse::class); ``` diff --git a/docs/13-api-reference.md b/docs/13-api-reference.md index caa6a0c..fc17071 100644 --- a/docs/13-api-reference.md +++ b/docs/13-api-reference.md @@ -3,7 +3,7 @@ This reference is split by where methods are available. - [API](03-api.md): `Api` setup methods and `Config`. -- [Resources](05-resources.md): resource modifiers and protected request helpers. +- [Resources](05-resources.md): resource classes and endpoint request helpers. - [Responses](06-responses.md): `Response`, `EntityInterface`, `ResponseEnvelopeInterface`, and `Context`. - [Authentication](07-authentication.md): `AuthBuilder` helpers and custom authentication. - [HTTP Client](08-http-client.md): `ClientBuilder` helpers and PSR-18/PSR-17 configuration. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 4371878..24259a4 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -60,11 +60,11 @@ The base class for endpoint groups. Responsibilities: -- Provide protected HTTP helpers like `get`, `post`, `put`, `patch`, and `delete`. +- Provide `endpoint()` as the SDK-author request builder entrypoint. - Hold immutable per-resource request options. - Allow generic query/header customization through primitives like `query`, `queries`, `header`, and `headers`. - Return a fresh resource instance by default when created through `Api::resource()`. -- Execute requests immediately when `get`, `post`, `put`, `patch`, or `delete` are called. +- Execute requests immediately through endpoint HTTP helpers like `get`, `post`, `put`, `patch`, and `delete`. Non-goals: @@ -78,28 +78,29 @@ Immutable request state used by resources. Responsibilities: -- Store per-resource/per-request query parameters. -- Store per-resource/per-request headers. +- Store endpoint-local query parameters. +- Store endpoint-local headers. - Store body/payload options when needed. - Merge cleanly with API defaults during request execution. -This avoids cloning and mutating the whole API instance for resource modifiers. +This avoids cloning and mutating the whole API instance for endpoint-specific request options. -Resource options should be configured fluently before calling the HTTP method: +Endpoint-local options should be configured fluently before calling the HTTP method: ```php return $this + ->endpoint() ->query('active', true) ->get('/users/{id}', ['id' => $id]) ->entity(User::class); ``` -The path remains an argument of `get`, `post`, `put`, `patch`, and `delete`. Query and header options are configured through fluent resource methods. +The path remains an argument of `get`, `post`, `put`, `patch`, and `delete`. Query and header options are configured through fluent endpoint methods. Query merge order: ```text -global API defaults < resource options < endpoint-specific options +global API defaults < endpoint options < endpoint method query argument ``` Builder-backed features can follow the same shape when endpoint-specific behavior is useful. @@ -316,7 +317,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon | `setBaseUrl` / `getBaseUrl` | Fluent `baseUrl(...)`, optional getter only if useful | | SDK-specific global options | Generic config bag exposed to resources/responses/entities through context | | Query/header defaults | Fluent `defaultQueries(...)`, `defaultHeaders(...)` | -| Per-resource query options | New `RequestOptions`, exposed through `Resource::query(...)` and SDK-specific traits | +| Per-resource query options | Removed as a generic base feature; SDK authors should use explicit method arguments or API-specific state and apply options through `Endpoint` | | `setAuthentication` | Fluent `auth()` helper wrapping HTTPlug authentication plus low-level authentication injection | | Client/factory injection | Keep builder-style or fluent config methods | | Plugins | Use HTTPlug `PluginClientBuilder`-style priority handling; preserve multiple plugins at the same priority | @@ -346,14 +347,13 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. - Prefer fluent configuration over public getters. - Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. -- Keep `Resource::query()`, `Resource::queries()`, `Resource::header()`, and `Resource::headers()` as generic public primitives. -- Resource modifiers should be immutable and return cloned resources. +- Do not keep generic query/header modifiers on `Resource`; `Endpoint` owns request-local options. - `get`, `post`, `put`, `patch`, and `delete` should execute immediately. - SDK authors choose whether resource methods return entities directly or custom response envelopes. - Resource constructors may remain public. - Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::envelope()`. -- No reset methods for resource options in the first phase. -- Merge order should be global defaults, then resource options, then endpoint-specific options. +- No reset methods for resource options are needed because generic resource options are not part of the base feature set. +- Merge order should be global defaults, then endpoint options, then endpoint method query arguments. - Client configuration is global API setup only. Do not add `Resource::client()`. - Defer request-local plugins, cache, hooks, and similar pipeline options until the request-local architecture is clearer. Avoid ad hoc builder cloning or one-off request option shapes. If request-local cache is added later, prefer a smaller cache options object that stores only override values such as default TTL, methods, and cache directives, then merge it with the API-level cache builder during send. - Header names should not be normalized manually. @@ -422,6 +422,7 @@ final class UserResource extends Resource public function all(): UserCollection { return $this + ->endpoint() ->query('active', true) ->get('/users') ->envelope(UserCollection::class); @@ -430,6 +431,7 @@ final class UserResource extends Resource public function find(int $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class, key: 'data'); } @@ -444,6 +446,7 @@ final class FixtureResource extends Resource public function find(int $id): FixtureItem { return $this + ->endpoint() ->get('/v3/football/fixtures/{id}', ['id' => $id]) ->envelope(FixtureItem::class); } diff --git a/src/Endpoint.php b/src/Endpoint.php new file mode 100644 index 0000000..5129c3e --- /dev/null +++ b/src/Endpoint.php @@ -0,0 +1,131 @@ +header('Content-Type', 'application/json') + ->body(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function form(array $data): static + { + return $this + ->header('Content-Type', 'application/x-www-form-urlencoded') + ->body(http_build_query($data)); + } + + public function body(mixed $body): static + { + if (is_array($body)) { + throw new \InvalidArgumentException('Use json() or form() to send array request data.'); + } + + if (!$body instanceof StreamInterface && !is_string($body) && $body !== null) { + throw new \InvalidArgumentException('Request body must be a string, stream, or null.'); + } + + return $this->withOptions($this->options->withBody($body)); + } + + public function query(string $name, mixed $value): static + { + return $this->withOptions($this->options->withQuery($name, $value)); + } + + public function queries(array $query): static + { + return $this->withOptions($this->options->withQueries($query)); + } + + public function header(string $name, mixed $value): static + { + return $this->withOptions($this->options->withHeader($name, $value)); + } + + public function headers(array $headers): static + { + return $this->withOptions($this->options->withHeaders($headers)); + } + + public function get(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::GET, $path, $pathParams, $query); + } + + public function post(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::POST, $path, $pathParams, $query); + } + + public function put(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::PUT, $path, $pathParams, $query); + } + + public function patch(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::PATCH, $path, $pathParams, $query); + } + + public function delete(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::DELETE, $path, $pathParams, $query); + } + + public function head(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::HEAD, $path, $pathParams, $query); + } + + public function options(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::OPTIONS, $path, $pathParams, $query); + } + + public function connect(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::CONNECT, $path, $pathParams, $query); + } + + public function trace(string $path, array $pathParams = [], array $query = []): Response + { + return $this->send(Method::TRACE, $path, $pathParams, $query); + } + + private function send(string $method, string $path, array $pathParams = [], array $query = []): Response + { + return $this->api->send( + method: $method, + path: $path, + pathParams: $pathParams, + options: $this->options->withQueries($query) + ); + } + + private function withOptions(RequestOptions $options): static + { + $clone = clone $this; + $clone->options = $options; + + return $clone; + } +} diff --git a/src/Resource.php b/src/Resource.php index 4e9e268..c270f8e 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -3,134 +3,20 @@ namespace ProgrammatorDev\Api; use ProgrammatorDev\Api\Config\Config; -use ProgrammatorDev\Api\Http\Method; -use ProgrammatorDev\Api\Request\RequestOptions; -use ProgrammatorDev\Api\Response\Response; -use Psr\Http\Message\StreamInterface; abstract class Resource { - private RequestOptions $options; - public function __construct( - protected readonly Api $api, - ?RequestOptions $options = null - ) { - $this->options = $options ?? new RequestOptions(); - } - - public function query(string $name, mixed $value): static - { - return $this->withOptions($this->options->withQuery($name, $value)); - } - - public function queries(array $query): static - { - return $this->withOptions($this->options->withQueries($query)); - } - - public function header(string $name, mixed $value): static - { - return $this->withOptions($this->options->withHeader($name, $value)); - } - - public function headers(array $headers): static - { - return $this->withOptions($this->options->withHeaders($headers)); - } - - public function json(array $data): static - { - return $this - ->header('Content-Type', 'application/json') - ->body(json_encode($data, JSON_THROW_ON_ERROR)); - } - - public function form(array $data): static - { - return $this - ->header('Content-Type', 'application/x-www-form-urlencoded') - ->body(http_build_query($data)); - } - - public function body(mixed $body): static - { - if (is_array($body)) { - throw new \InvalidArgumentException('Use json() or form() to send array request data.'); - } - - if (!$body instanceof StreamInterface && !is_string($body) && $body !== null) { - throw new \InvalidArgumentException('Request body must be a string, stream, or null.'); - } - - return $this->withOptions($this->options->withBody($body)); - } - - protected function get(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::GET, $path, $pathParams, $query); - } + protected readonly Api $api + ) {} - protected function post(string $path, array $pathParams = [], array $query = []): Response + protected function endpoint(): Endpoint { - return $this->send(Method::POST, $path, $pathParams, $query); - } - - protected function put(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::PUT, $path, $pathParams, $query); - } - - protected function patch(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::PATCH, $path, $pathParams, $query); - } - - protected function delete(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::DELETE, $path, $pathParams, $query); - } - - protected function head(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::HEAD, $path, $pathParams, $query); - } - - protected function options(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::OPTIONS, $path, $pathParams, $query); - } - - protected function connect(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::CONNECT, $path, $pathParams, $query); - } - - protected function trace(string $path, array $pathParams = [], array $query = []): Response - { - return $this->send(Method::TRACE, $path, $pathParams, $query); + return Endpoint::for($this->api); } protected function config(): Config { return $this->api->config(); } - - protected function send(string $method, string $path, array $pathParams = [], array $query = []): Response - { - return $this->api->send( - method: $method, - path: $path, - pathParams: $pathParams, - options: $this->options->withQueries($query) - ); - } - - private function withOptions(RequestOptions $options): static - { - $clone = clone $this; - $clone->options = $options; - - return $clone; - } } diff --git a/tests/Fixture/RawResource.php b/tests/Fixture/RawResource.php index fb2f5d4..1743cfb 100644 --- a/tests/Fixture/RawResource.php +++ b/tests/Fixture/RawResource.php @@ -9,11 +9,11 @@ class RawResource extends Resource { public function fetch(): Response { - return $this->get('/raw'); + return $this->endpoint()->get('/raw'); } public function absolute(string $url): Response { - return $this->get($url); + return $this->endpoint()->get($url); } } diff --git a/tests/Fixture/Simple/SimpleResource.php b/tests/Fixture/Simple/SimpleResource.php index 3fbf50b..bf3c971 100644 --- a/tests/Fixture/Simple/SimpleResource.php +++ b/tests/Fixture/Simple/SimpleResource.php @@ -9,6 +9,7 @@ class SimpleResource extends Resource public function find(int|string $id): SimpleEntity { return $this + ->endpoint() ->get('/items/{id}', ['id' => $id]) ->entity(SimpleEntity::class); } @@ -16,6 +17,7 @@ public function find(int|string $id): SimpleEntity public function findResponse(int|string $id): SimpleResponse { return $this + ->endpoint() ->get('/items/{id}', ['id' => $id]) ->envelope(SimpleResponse::class); } @@ -26,6 +28,7 @@ public function findResponse(int|string $id): SimpleResponse public function all(): array { return $this + ->endpoint() ->get('/items') ->collection(SimpleEntity::class, key: 'data'); } diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 82e7983..c912506 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -10,41 +10,42 @@ class UserResource extends Resource public function sendWithVerb(string $verb): void { match ($verb) { - 'GET' => $this->get('/users'), - 'POST' => $this->post('/users'), - 'PUT' => $this->put('/users/{id}', ['id' => 1]), - 'PATCH' => $this->patch('/users/{id}', ['id' => 1]), - 'DELETE' => $this->delete('/users/{id}', ['id' => 1]), - 'HEAD' => $this->head('/users'), - 'OPTIONS' => $this->options('/users'), - 'CONNECT' => $this->connect('/users'), - 'TRACE' => $this->trace('/users'), + 'GET' => $this->endpoint()->get('/users'), + 'POST' => $this->endpoint()->post('/users'), + 'PUT' => $this->endpoint()->put('/users/{id}', ['id' => 1]), + 'PATCH' => $this->endpoint()->patch('/users/{id}', ['id' => 1]), + 'DELETE' => $this->endpoint()->delete('/users/{id}', ['id' => 1]), + 'HEAD' => $this->endpoint()->head('/users'), + 'OPTIONS' => $this->endpoint()->options('/users'), + 'CONNECT' => $this->endpoint()->connect('/users'), + 'TRACE' => $this->endpoint()->trace('/users'), }; } public function createWithJson(array $data): void { - $this->json($data)->post('/users'); + $this->endpoint()->json($data)->post('/users'); } public function createWithForm(array $data): void { - $this->form($data)->post('/users'); + $this->endpoint()->form($data)->post('/users'); } public function createWithBody(string|StreamInterface|null $body): void { - $this->body($body)->post('/users'); + $this->endpoint()->body($body)->post('/users'); } public function createWithInvalidBody(mixed $body): void { - $this->body($body)->post('/users'); + $this->endpoint()->body($body)->post('/users'); } public function all(): array { return $this + ->endpoint() ->get('/users') ->collection(User::class, key: 'data'); } @@ -52,6 +53,7 @@ public function all(): array public function find(int|string $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } @@ -59,6 +61,7 @@ public function find(int|string $id): User public function findFromEnvelope(int|string $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class, key: 'data'); } @@ -66,6 +69,7 @@ public function findFromEnvelope(int|string $id): User public function findEnvelope(int|string $id): UserEnvelope { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->envelope(UserEnvelope::class); } @@ -73,15 +77,45 @@ public function findEnvelope(int|string $id): UserEnvelope public function findWithEndpointLocale(int|string $id, string $locale): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id], ['locale' => $locale]) ->entity(User::class); } + public function findWithEndpointOptions(int|string $id): User + { + return $this + ->endpoint() + ->queries(['active' => true]) + ->headers(['X-Tenant' => 'acme']) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + public function findWithConfiguredTimezone(int|string $id): User { return $this + ->endpoint() ->query('timezone', $this->config()->get('timezone')) ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } + + public function findWithActive(int|string $id, bool $active = true): User + { + return $this + ->endpoint() + ->query('active', $active) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findWithEmptyQuery(int|string $id): User + { + return $this + ->endpoint() + ->query('empty', null) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 42215af..c764d46 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -101,6 +101,18 @@ public function testResourceCanSendStreamBody(): void $this->assertSame('stream-body', (string) $request->getBody()); } + public function testEndpointCanSetRequestQueryAndHeaders(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->findWithEndpointOptions(1); + + $request = $this->client->getLastRequest(); + + $this->assertSame('https://api.example.com/users/1?locale=en&active=1', (string) $request->getUri()); + $this->assertSame('acme', $request->getHeaderLine('X-Tenant')); + } + public function testResourceBodyRejectsArrayData(): void { $this->expectException(\InvalidArgumentException::class); @@ -118,14 +130,14 @@ public function testResourcePathParametersAreEncoded(): void $this->assertSame('https://api.example.com/users/john%2Fdoe?locale=en', (string) $this->client->getLastRequest()->getUri()); } - public function testResourceOptionsAreImmutable(): void + public function testEndpointOptionsDoNotLeakBetweenResourceCalls(): 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(); - $users->query('active', true)->find(1); + $users->findWithActive(1); $users->find(2); $requests = $this->client->getRequests(); @@ -134,13 +146,12 @@ public function testResourceOptionsAreImmutable(): void $this->assertSame('https://api.example.com/users/2?locale=en', (string) $requests[1]->getUri()); } - public function testEndpointQueryOverridesResourceAndGlobalDefaults(): void + public function testEndpointQueryOverridesGlobalDefaults(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); $this->api ->users() - ->query('locale', 'fr') ->findWithEndpointLocale(1, 'pt'); $this->assertSame('https://api.example.com/users/1?locale=pt', (string) $this->client->getLastRequest()->getUri()); @@ -163,8 +174,7 @@ public function testNullQueryValuesAreOmitted(): void $this->api ->users() - ->query('empty', null) - ->find(1); + ->findWithEmptyQuery(1); $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); } From e311f17734bc70930f523986750aa65e1a962dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 18:46:10 +0100 Subject: [PATCH 54/88] feat(v3): add request cache overrides --- docs/03-api.md | 2 +- docs/05-resources.md | 13 ++++++ docs/09-cache.md | 36 +++++++++++++++ docs/v3-architecture-plan.md | 3 +- src/Api.php | 5 ++- src/Endpoint.php | 30 +++++++++++-- src/Http/Transport.php | 27 ++++++++---- src/Request/PipelineOption.php | 10 +++++ src/Request/PipelineOptions.php | 67 ++++++++++++++++++++++++++++ src/Resource.php | 29 ++++++++++--- tests/Fixture/UserResource.php | 22 +++++++++- tests/Integration/CacheTest.php | 70 ++++++++++++++++++++++++++++++ tests/Unit/PipelineOptionsTest.php | 45 +++++++++++++++++++ 13 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 src/Request/PipelineOption.php create mode 100644 src/Request/PipelineOptions.php create mode 100644 tests/Unit/PipelineOptionsTest.php diff --git a/docs/03-api.md b/docs/03-api.md index ed960ea..f909a9e 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -8,7 +8,7 @@ Methods not listed here are legacy, internal, or still being reshaped for v3. Public low-level request helper. -Most SDK methods should use resources and the protected resource verb helpers. `send()` is useful when an SDK author or advanced SDK user needs to execute a request directly while still using the configured base URL, defaults, auth, plugins, cache, hooks, decoding, and errors. +Most SDK methods should use resources and endpoint request helpers. `send()` is useful when an SDK author or advanced SDK user needs to execute a request directly while still using the configured base URL, defaults, auth, plugins, cache, hooks, decoding, and errors. ```php $response = $api->send('GET', '/users/{id}', ['id' => 1]); diff --git a/docs/05-resources.md b/docs/05-resources.md index fa79e34..58baf0c 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -137,6 +137,19 @@ array $pathParams = [] array $query = [] ``` +## Resource Cache Overrides + +`withCache()` lets SDK users override cache behavior for one resource chain while keeping query, headers, body, and verbs inside `Endpoint`. + +```php +$users = $api + ->users() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->all(); +``` + +See [Cache](09-cache.md) for endpoint cache defaults, merge order, and the API-level cache requirement. + ## Navigation - Previous: [Resource Authoring](04-resource-authoring.md) diff --git a/docs/09-cache.md b/docs/09-cache.md index 12ac24f..62296cf 100644 --- a/docs/09-cache.md +++ b/docs/09-cache.md @@ -39,6 +39,42 @@ $this->cache($pool)->responseCacheDirectives(['max-age']); Sets the response cache directives respected by the cache plugin. +## Resource And Endpoint Overrides + +SDK users can override cache behavior for one resource chain with `withCache()`: + +```php +$fixtures = $api + ->fixtures() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->live(); +``` + +SDK authors can set endpoint-specific cache defaults on the endpoint builder: + +```php +public function live(): FixtureCollection +{ + return $this + ->endpoint() + ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->get('/fixtures/live') + ->envelope(FixtureCollection::class); +} +``` + +Cache overrides require API-level cache configuration, because the global cache configuration provides the PSR-6 pool: + +```php +$api->setup()->cache($pool); +``` + +Cache configuration is merged in this order: + +```text +API cache config < endpoint cache defaults < resource withCache override +``` + ## Internal Order The cache plugin runs at priority `20`, after authentication and before the logger plugin. diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 24259a4..93c9cc4 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -115,8 +115,9 @@ Request-local overrides should live on the pending request/resource flow instead ```php return $this - ->get('/weather') + ->endpoint() ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(300)) + ->get('/weather') ->entity(CurrentWeather::class); ``` diff --git a/src/Api.php b/src/Api.php index 7f3b4f4..2a29aa1 100644 --- a/src/Api.php +++ b/src/Api.php @@ -14,6 +14,7 @@ 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; use ProgrammatorDev\Api\Response\ResponseDecoder; @@ -69,7 +70,8 @@ public function send( string $method, string $path, array $pathParams = [], - ?RequestOptions $options = null + ?RequestOptions $options = null, + ?PipelineOptions $pipelineOptions = null ): Response { $options ??= new RequestOptions(); @@ -80,6 +82,7 @@ public function send( path: $path, pathParams: $pathParams, options: $options, + pipelineOptions: $pipelineOptions, context: $context ); diff --git a/src/Endpoint.php b/src/Endpoint.php index 5129c3e..943a383 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -3,6 +3,8 @@ namespace ProgrammatorDev\Api; use ProgrammatorDev\Api\Http\Method; +use ProgrammatorDev\Api\Request\PipelineOption; +use ProgrammatorDev\Api\Request\PipelineOptions; use ProgrammatorDev\Api\Request\RequestOptions; use ProgrammatorDev\Api\Response\Response; use Psr\Http\Message\StreamInterface; @@ -11,12 +13,23 @@ class Endpoint { public function __construct( private readonly Api $api, - private RequestOptions $options + private RequestOptions $options, + private PipelineOptions $pipelineOptions ) {} - public static function for(Api $api): static + public static function for(Api $api, ?PipelineOptions $pipelineOptions = null): static { - return new static($api, new RequestOptions()); + return new static($api, new RequestOptions(), $pipelineOptions ?? new PipelineOptions()); + } + + /** + * @param callable(\ProgrammatorDev\Api\Builder\CacheBuilder): mixed $configure + */ + public function cache(callable $configure): static + { + return $this->withPipelineOptions( + $this->pipelineOptions->withDefault(PipelineOption::CACHE, $configure) + ); } public function json(array $data): static @@ -117,7 +130,8 @@ private function send(string $method, string $path, array $pathParams = [], arra method: $method, path: $path, pathParams: $pathParams, - options: $this->options->withQueries($query) + options: $this->options->withQueries($query), + pipelineOptions: $this->pipelineOptions ); } @@ -128,4 +142,12 @@ private function withOptions(RequestOptions $options): static return $clone; } + + private function withPipelineOptions(PipelineOptions $pipelineOptions): static + { + $clone = clone $this; + $clone->pipelineOptions = $pipelineOptions; + + return $clone; + } } diff --git a/src/Http/Transport.php b/src/Http/Transport.php index 9fd18f6..ca81a31 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -19,6 +19,8 @@ use ProgrammatorDev\Api\Context\RequestContext; use ProgrammatorDev\Api\Context\ResponseContext; use ProgrammatorDev\Api\Helper\UrlHelper; +use ProgrammatorDev\Api\Request\PipelineOption; +use ProgrammatorDev\Api\Request\PipelineOptions; use ProgrammatorDev\Api\Request\RequestOptions; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Message\RequestInterface; @@ -54,10 +56,12 @@ public function send( string $path, array $pathParams = [], ?RequestOptions $options = null, + ?PipelineOptions $pipelineOptions = null, ?Context $context = null ): ResponseInterface { $options ??= new RequestOptions(); + $pipelineOptions ??= new PipelineOptions(); $context ??= new Context(); $path = $this->buildPath($path, $pathParams); $query = $options->getQuery(); @@ -83,7 +87,7 @@ public function send( ); $response = $this->clientBuilder - ->getClient($this->buildPlugins()) + ->getClient($this->buildPlugins($pipelineOptions)) ->sendRequest($request); return $this->hookBuilder->applyAfterResponseHooks( @@ -91,7 +95,7 @@ public function send( ); } - private function buildPlugins(): array + private function buildPlugins(PipelineOptions $pipelineOptions): array { $plugins = new PluginBuilder(); @@ -112,7 +116,7 @@ private function buildPlugins(): array ); } - if ($cachePlugin = $this->buildCachePlugin()) { + if ($cachePlugin = $this->buildCachePlugin($pipelineOptions)) { $plugins->add( plugin: $cachePlugin, priority: self::CACHE_PLUGIN_PRIORITY @@ -131,16 +135,23 @@ private function buildPlugins(): array return $plugins->getPlugins(); } - private function buildCachePlugin(): ?Plugin + private function buildCachePlugin(PipelineOptions $pipelineOptions): ?Plugin { if ($this->cacheBuilder === null) { + if ($pipelineOptions->has(PipelineOption::CACHE)) { + throw new \RuntimeException('Endpoint cache overrides require API-level cache configuration.'); + } + return null; } + $cacheBuilder = clone $this->cacheBuilder; + $pipelineOptions->applyTo(PipelineOption::CACHE, $cacheBuilder); + $cacheOptions = [ - 'default_ttl' => $this->cacheBuilder->getDefaultTtl(), - 'methods' => $this->cacheBuilder->getMethods(), - 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), + 'default_ttl' => $cacheBuilder->getDefaultTtl(), + 'methods' => $cacheBuilder->getMethods(), + 'respect_response_cache_directives' => $cacheBuilder->getResponseCacheDirectives(), 'cache_listeners' => [] ]; @@ -149,7 +160,7 @@ private function buildCachePlugin(): ?Plugin } return new CachePlugin( - $this->cacheBuilder->getPool(), + $cacheBuilder->getPool(), $this->clientBuilder->getStreamFactory(), $cacheOptions ); diff --git a/src/Request/PipelineOption.php b/src/Request/PipelineOption.php new file mode 100644 index 0000000..678d07d --- /dev/null +++ b/src/Request/PipelineOption.php @@ -0,0 +1,10 @@ +> $defaults + * @param array> $overrides + */ + public function __construct( + private readonly array $defaults = [], + private readonly array $overrides = [] + ) {} + + public function has(string $key): bool + { + return !empty($this->defaults[$key]) || !empty($this->overrides[$key]); + } + + /** + * @param callable(object): mixed $configure + */ + public function withDefault(string $key, callable $configure): self + { + $defaults = $this->defaults; + $defaults[$key][] = $configure; + + return new self($defaults, $this->overrides); + } + + /** + * @param callable(object): mixed $configure + */ + public function withOverride(string $key, callable $configure): self + { + $overrides = $this->overrides; + $overrides[$key][] = $configure; + + return new self($this->defaults, $overrides); + } + + /** + * @template T of object + * @param T $builder + * @return T + */ + public function applyTo(string $key, object $builder): object + { + foreach ($this->configurers($key) as $configure) { + $configure($builder); + } + + return $builder; + } + + /** + * @return list + */ + private function configurers(string $key): array + { + return [ + ...($this->defaults[$key] ?? []), + ...($this->overrides[$key] ?? []), + ]; + } +} diff --git a/src/Resource.php b/src/Resource.php index c270f8e..710696a 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -2,21 +2,40 @@ namespace ProgrammatorDev\Api; -use ProgrammatorDev\Api\Config\Config; +use ProgrammatorDev\Api\Builder\CacheBuilder; +use ProgrammatorDev\Api\Request\PipelineOption; +use ProgrammatorDev\Api\Request\PipelineOptions; abstract class Resource { + private PipelineOptions $pipelineOptions; + public function __construct( protected readonly Api $api - ) {} + ) { + $this->pipelineOptions = new PipelineOptions(); + } + + /** + * @param callable(CacheBuilder): mixed $configure + */ + public function withCache(callable $configure): static + { + return $this->withPipelineOptions( + $this->pipelineOptions->withOverride(PipelineOption::CACHE, $configure) + ); + } protected function endpoint(): Endpoint { - return Endpoint::for($this->api); + return Endpoint::for($this->api, $this->pipelineOptions); } - protected function config(): Config + private function withPipelineOptions(PipelineOptions $pipelineOptions): static { - return $this->api->config(); + $clone = clone $this; + $clone->pipelineOptions = $pipelineOptions; + + return $clone; } } diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index c912506..47489d2 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\Api\Test\Fixture; use ProgrammatorDev\Api\Resource; +use ProgrammatorDev\Api\Response\Response; use Psr\Http\Message\StreamInterface; class UserResource extends Resource @@ -42,6 +43,25 @@ public function createWithInvalidBody(mixed $body): void $this->endpoint()->body($body)->post('/users'); } + public function createWithEndpointCache(array $data): Response + { + return $this + ->endpoint() + ->cache(fn($cache) => $cache->methods(['POST'])) + ->json($data) + ->post('/users'); + } + + public function createWithChainedEndpointCache(array $data): Response + { + return $this + ->endpoint() + ->cache(fn($cache) => $cache->methods(['POST'])) + ->cache(fn($cache) => $cache->methods(['GET'])) + ->json($data) + ->post('/users'); + } + public function all(): array { return $this @@ -96,7 +116,7 @@ public function findWithConfiguredTimezone(int|string $id): User { return $this ->endpoint() - ->query('timezone', $this->config()->get('timezone')) + ->query('timezone', $this->api->config()->get('timezone')) ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php index 6d1221e..3d4a48c 100644 --- a/tests/Integration/CacheTest.php +++ b/tests/Integration/CacheTest.php @@ -4,6 +4,7 @@ use Nyholm\Psr7\Response; use ProgrammatorDev\Api\Test\Fixture\JsonApi; +use ProgrammatorDev\Api\Test\Fixture\FakeApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -28,4 +29,73 @@ public function testSdkUserCanConfigureCache(): void $this->assertSame(['id' => 1], $second->data()); $this->assertCount(1, $client->getRequests()); } + + public function testEndpointCanOverrideCacheConfiguration(): 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()->createWithEndpointCache(['name' => 'John']); + $second = $api->users()->createWithEndpointCache(['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( + 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() + ->withCache(fn($cache) => $cache->methods(['GET'])) + ->createWithEndpointCache(['name' => 'John']); + + $second = $api + ->users() + ->withCache(fn($cache) => $cache->methods(['GET'])) + ->createWithEndpointCache(['name' => 'John']); + + $this->assertSame(['id' => 1, 'name' => 'John'], $first->data()); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $second->data()); + $this->assertCount(2, $client->getRequests()); + } + + public function testEndpointCacheOverridesAreAppliedInFluentOrder(): 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()->createWithChainedEndpointCache(['name' => 'John']); + $second = $api->users()->createWithChainedEndpointCache(['name' => 'John']); + + $this->assertSame(['id' => 1, 'name' => 'John'], $first->data()); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $second->data()); + $this->assertCount(2, $client->getRequests()); + } + + public function testResourceCacheOverrideRequiresGlobalCacheConfiguration(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Endpoint cache overrides require API-level cache configuration.'); + + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + $api = new FakeApi($client); + + $api + ->users() + ->withCache(fn($cache) => $cache->defaultTtl(60)) + ->find(1); + } } diff --git a/tests/Unit/PipelineOptionsTest.php b/tests/Unit/PipelineOptionsTest.php new file mode 100644 index 0000000..1b18137 --- /dev/null +++ b/tests/Unit/PipelineOptionsTest.php @@ -0,0 +1,45 @@ +values[] = $value; + } + }; + + $options = (new PipelineOptions()) + ->withOverride('feature', fn(object $builder) => $builder->add('override')) + ->withDefault('feature', fn(object $builder) => $builder->add('default')); + + $this->assertTrue($options->has('feature')); + $this->assertFalse($options->has('other')); + + $this->assertSame($builder, $options->applyTo('feature', $builder)); + $this->assertSame(['default', 'override'], $builder->values); + } + + public function testPipelineOptionsIgnoreUnrelatedKeys(): void + { + $builder = new class { + public array $values = []; + }; + + $options = (new PipelineOptions()) + ->withDefault('feature', fn(object $builder) => $builder->values[] = 'feature'); + + $options->applyTo('other', $builder); + + $this->assertSame([], $builder->values); + } +} From 4c1372519c40871d8bd5d12e96d84ac542d498f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 18:54:05 +0100 Subject: [PATCH 55/88] refactor(v3): simplify endpoint construction --- src/Endpoint.php | 10 ++++------ src/Resource.php | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Endpoint.php b/src/Endpoint.php index 943a383..0936cc4 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -11,15 +11,13 @@ class Endpoint { + private RequestOptions $options; + public function __construct( private readonly Api $api, - private RequestOptions $options, private PipelineOptions $pipelineOptions - ) {} - - public static function for(Api $api, ?PipelineOptions $pipelineOptions = null): static - { - return new static($api, new RequestOptions(), $pipelineOptions ?? new PipelineOptions()); + ) { + $this->options = new RequestOptions(); } /** diff --git a/src/Resource.php b/src/Resource.php index 710696a..c834f01 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -28,7 +28,7 @@ public function withCache(callable $configure): static protected function endpoint(): Endpoint { - return Endpoint::for($this->api, $this->pipelineOptions); + return new Endpoint($this->api, $this->pipelineOptions); } private function withPipelineOptions(PipelineOptions $pipelineOptions): static From c293df1dc113f52a0825dce37a3eca9e21ccd428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 18:58:54 +0100 Subject: [PATCH 56/88] feat(v3): simplify public send options --- docs/02-design-approach.md | 7 ++++++- docs/03-api.md | 14 +++++++++++++- docs/v3-architecture-plan.md | 2 +- src/Api.php | 11 +++++++++-- src/Endpoint.php | 4 +++- tests/Integration/ApiTest.php | 20 ++++++++++++++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) diff --git a/docs/02-design-approach.md b/docs/02-design-approach.md index 3235f2b..8838e22 100644 --- a/docs/02-design-approach.md +++ b/docs/02-design-approach.md @@ -69,7 +69,12 @@ This keeps the main SDK autocomplete focused while preserving hackability. If a concrete SDK does not expose an endpoint yet, `send()` can still use the configured SDK pipeline: ```php -$response = $api->send('GET', '/new-endpoint/{id}', ['id' => 1]); +$response = $api->send( + method: 'GET', + path: '/new-endpoint/{id}', + pathParams: ['id' => 1], + query: ['include' => 'details'] +); ``` That request still uses configured base URL, defaults, auth, plugins, cache, hooks, response decoding, and error handling. diff --git a/docs/03-api.md b/docs/03-api.md index f909a9e..b4d97ca 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -4,7 +4,7 @@ Methods not listed here are legacy, internal, or still being reshaped for v3. -## `send(string $method, string $path, array $pathParams = [], ?RequestOptions $options = null): Response` +## `send(string $method, string $path, array $pathParams = [], array $query = [], array $headers = [], string|StreamInterface|null $body = null): Response` Public low-level request helper. @@ -14,6 +14,18 @@ Most SDK methods should use resources and endpoint request helpers. `send()` is $response = $api->send('GET', '/users/{id}', ['id' => 1]); ``` +Common request inputs can be passed directly: + +```php +$response = $api->send( + method: 'POST', + path: '/users', + query: ['active' => true], + headers: ['Content-Type' => 'application/json'], + body: '{"name":"John"}' +); +``` + Path parameters are encoded and replaced in `{name}` placeholders. `send()` still runs through the configured SDK pipeline: diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 93c9cc4..14af489 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -74,7 +74,7 @@ Non-goals: ### `RequestOptions` -Immutable request state used by resources. +Immutable request state used internally by `Endpoint`. Responsibilities: diff --git a/src/Api.php b/src/Api.php index 2a29aa1..f13e719 100644 --- a/src/Api.php +++ b/src/Api.php @@ -21,6 +21,7 @@ use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Cache\CacheItemPoolInterface; +use Psr\Http\Message\StreamInterface; use Psr\Log\LoggerInterface; abstract class Api @@ -70,11 +71,17 @@ public function send( string $method, string $path, array $pathParams = [], - ?RequestOptions $options = null, + array $query = [], + array $headers = [], + string|StreamInterface|null $body = null, ?PipelineOptions $pipelineOptions = null ): Response { - $options ??= new RequestOptions(); + $options = (new RequestOptions()) + ->withQueries($query) + ->withHeaders($headers) + ->withBody($body); + $context = new Context($this->config); $response = $this->transport()->send( diff --git a/src/Endpoint.php b/src/Endpoint.php index 0936cc4..e2d100d 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -128,7 +128,9 @@ private function send(string $method, string $path, array $pathParams = [], arra method: $method, path: $path, pathParams: $pathParams, - options: $this->options->withQueries($query), + query: $this->options->withQueries($query)->getQuery(), + headers: $this->options->getHeaders(), + body: $this->options->getBody(), pipelineOptions: $this->pipelineOptions ); } diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 6f4574a..dbb4746 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -42,6 +42,26 @@ public function testApiCanSendPublicRequest(): void $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); } + public function testApiCanSendPublicRequestWithQueryHeadersAndBody(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + + (new FakeApi($client))->send( + method: Method::POST, + path: '/users', + query: ['active' => true], + headers: ['Content-Type' => 'application/json'], + body: '{"name":"John"}' + ); + + $request = $client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://api.example.com/users?locale=en&active=1', (string) $request->getUri()); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame('{"name":"John"}', (string) $request->getBody()); + } + public function testApiCanSendRequestWithDefaultQuery(): void { $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); From 2463df6d01c877d4476c396b1a36ad07612a882f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 19:02:03 +0100 Subject: [PATCH 57/88] docs(v3): refresh architecture wording --- docs/03-api.md | 2 +- docs/04-resource-authoring.md | 2 +- docs/v3-architecture-plan.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/03-api.md b/docs/03-api.md index b4d97ca..c3add2a 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -2,7 +2,7 @@ `Api` is the SDK facade. Concrete SDKs extend it and expose resources through purpose-built methods. -Methods not listed here are legacy, internal, or still being reshaped for v3. +This page documents the public API facade methods available to SDK authors and advanced SDK users. ## `send(string $method, string $path, array $pathParams = [], array $query = [], array $headers = [], string|StreamInterface|null $body = null): Response` diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index b67b62f..2460734 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -37,7 +37,7 @@ final class ExampleApi extends Api } ``` -`Api::resource()` creates a fresh resource instance. Resource option modifiers are immutable, so fluent customizations do not leak into later calls. +`Api::resource()` creates a fresh resource instance. Resource-chain infrastructure overrides, such as `withCache()`, are immutable, so fluent customizations do not leak into later calls. ## Endpoint Requests diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 14af489..8be86b9 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -353,10 +353,10 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - SDK authors choose whether resource methods return entities directly or custom response envelopes. - Resource constructors may remain public. - Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::envelope()`. -- No reset methods for resource options are needed because generic resource options are not part of the base feature set. +- No reset methods are needed for generic query/header customization because those methods are not part of the base `Resource` API. - Merge order should be global defaults, then endpoint options, then endpoint method query arguments. - Client configuration is global API setup only. Do not add `Resource::client()`. -- Defer request-local plugins, cache, hooks, and similar pipeline options until the request-local architecture is clearer. Avoid ad hoc builder cloning or one-off request option shapes. If request-local cache is added later, prefer a smaller cache options object that stores only override values such as default TTL, methods, and cache directives, then merge it with the API-level cache builder during send. +- Request-local cache now uses `PipelineOptions` so endpoint defaults and resource-chain overrides can be layered over the API-level cache builder. Defer request-local plugins, logger, hooks, and similar pipeline features until there is a concrete need, and reuse the same pipeline option shape where it fits. - Header names should not be normalized manually. - Path parameters should be encoded with `rawurlencode`. - Query strings should use `http_build_query(..., PHP_QUERY_RFC3986)`. From 2b01aee21fb2d3218d34de84b00d1eca17e540f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 22:52:50 +0100 Subject: [PATCH 58/88] test(v3): remove redundant simple api proof --- docs/v3-architecture-plan.md | 13 ++-- tests/Fixture/Simple/SimpleApi.php | 28 -------- tests/Fixture/Simple/SimpleEntity.php | 46 ------------- tests/Fixture/Simple/SimpleResource.php | 35 ---------- tests/Fixture/Simple/SimpleResponse.php | 40 ------------ tests/Integration/ApiTest.php | 22 +++++++ tests/Integration/SimpleApiProofTest.php | 82 ------------------------ 7 files changed, 28 insertions(+), 238 deletions(-) delete mode 100644 tests/Fixture/Simple/SimpleApi.php delete mode 100644 tests/Fixture/Simple/SimpleEntity.php delete mode 100644 tests/Fixture/Simple/SimpleResource.php delete mode 100644 tests/Fixture/Simple/SimpleResponse.php delete mode 100644 tests/Integration/SimpleApiProofTest.php diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 8be86b9..66e0aaa 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -12,6 +12,7 @@ final class UserResource extends Resource public function find(int $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } @@ -61,10 +62,9 @@ The base class for endpoint groups. Responsibilities: - Provide `endpoint()` as the SDK-author request builder entrypoint. -- Hold immutable per-resource request options. -- Allow generic query/header customization through primitives like `query`, `queries`, `header`, and `headers`. +- Hold immutable resource-chain infrastructure overrides such as `withCache()`. - Return a fresh resource instance by default when created through `Api::resource()`. -- Execute requests immediately through endpoint HTTP helpers like `get`, `post`, `put`, `patch`, and `delete`. +- Keep SDK-user autocomplete focused on domain methods and explicit infrastructure overrides. Non-goals: @@ -475,7 +475,6 @@ Future-phase questions should be answered when that phase starts, not before: - Exact hook method names and context details. - Whether any public configuration getters are useful for testing or advanced extension. - Whether `Method` remains as a tiny compatibility helper or is removed entirely. -- How endpoint-local cache options should work without muddying request options or cloning full API builders. - Whether `config()` ever supports nested keys. - Whether a future `collect()` helper should return a small generic collection object. @@ -483,14 +482,14 @@ Future-phase questions should be answered when that phase starts, not before: 1. Add fake SDK fixtures under tests. 2. Add `Resource`, `RequestOptions`, `Response`, and `EntityInterface`. -3. Add protected/fluent resource creation and request execution to `Api`. -4. Prove one simple endpoint flow with a mock PSR client: +3. Add protected/fluent resource creation and endpoint request execution. +4. Prove endpoint flows with focused fake resources and mock PSR clients: ```php $user = $api->users()->find(1); ``` -5. Add tests for path parameter replacement, fluent query options, query merge order, and entity mapping. +5. Add tests for path parameter replacement, endpoint query options, query merge order, and entity mapping. Do not add collection mapping, custom envelopes, SDK config, entity context, JSON decoding, errors, hooks, body helpers, auth, plugins, cache, or logger in the first slice. Preserve momentum by getting the authoring experience right first. diff --git a/tests/Fixture/Simple/SimpleApi.php b/tests/Fixture/Simple/SimpleApi.php deleted file mode 100644 index d397db6..0000000 --- a/tests/Fixture/Simple/SimpleApi.php +++ /dev/null @@ -1,28 +0,0 @@ -config($options, defaults: [ - 'locale' => 'en', - 'version' => 'v1', - ]); - - $this->baseUrl('https://api.example.com'); - $this->auth()->query('api_key', $apiKey); - $this->defaultQueries($this->config()->only('locale', 'version')); - $this->responses()->json(); - } - - public function items(): SimpleResource - { - return $this->resource(SimpleResource::class); - } -} diff --git a/tests/Fixture/Simple/SimpleEntity.php b/tests/Fixture/Simple/SimpleEntity.php deleted file mode 100644 index e765683..0000000 --- a/tests/Fixture/Simple/SimpleEntity.php +++ /dev/null @@ -1,46 +0,0 @@ -config()->get('locale'), - version: $context?->config()->get('version') - ); - } - - public function getId(): int - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function getLocale(): ?string - { - return $this->locale; - } - - public function getVersion(): ?string - { - return $this->version; - } -} diff --git a/tests/Fixture/Simple/SimpleResource.php b/tests/Fixture/Simple/SimpleResource.php deleted file mode 100644 index bf3c971..0000000 --- a/tests/Fixture/Simple/SimpleResource.php +++ /dev/null @@ -1,35 +0,0 @@ -endpoint() - ->get('/items/{id}', ['id' => $id]) - ->entity(SimpleEntity::class); - } - - public function findResponse(int|string $id): SimpleResponse - { - return $this - ->endpoint() - ->get('/items/{id}', ['id' => $id]) - ->envelope(SimpleResponse::class); - } - - /** - * @return SimpleEntity[] - */ - public function all(): array - { - return $this - ->endpoint() - ->get('/items') - ->collection(SimpleEntity::class, key: 'data'); - } -} diff --git a/tests/Fixture/Simple/SimpleResponse.php b/tests/Fixture/Simple/SimpleResponse.php deleted file mode 100644 index 6c44c1b..0000000 --- a/tests/Fixture/Simple/SimpleResponse.php +++ /dev/null @@ -1,40 +0,0 @@ -entity(SimpleEntity::class), - statusCode: $response->raw()->getStatusCode(), - locale: $context?->config()->get('locale') - ); - } - - public function getEntity(): SimpleEntity - { - return $this->entity; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - - public function getLocale(): ?string - { - return $this->locale; - } -} diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index dbb4746..7b6983a 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -73,6 +73,28 @@ public function testApiCanSendRequestWithDefaultQuery(): void $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $client->getLastRequest()->getUri()); } + public function testApiCanUseConfigValuesAsDefaultQueries(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + + $api = new class extends Api {}; + $setup = $api->setup(); + + $setup->client($client); + $setup + ->baseUrl('https://api.example.com') + ->defaultQueries($api->config([ + 'locale' => 'pt', + 'version' => 'v2', + 'internal' => true, + ])->only('locale', 'version')); + $setup->responses()->json(); + + $api->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame('https://api.example.com/users/1?locale=pt&version=v2', (string) $client->getLastRequest()->getUri()); + } + public function testApiCanSendRequestWithDefaultHeader(): void { $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); diff --git a/tests/Integration/SimpleApiProofTest.php b/tests/Integration/SimpleApiProofTest.php deleted file mode 100644 index 487d4e9..0000000 --- a/tests/Integration/SimpleApiProofTest.php +++ /dev/null @@ -1,82 +0,0 @@ -mockClient(new Response(body: self::ITEM_RESPONSE)); - - $api = new SimpleApi('test-key', ['locale' => 'pt', 'version' => 'v2']); - $api->setup()->client($client); - - $item = $api->items()->find(1); - - $this->assertInstanceOf(SimpleEntity::class, $item); - $this->assertSame(1, $item->getId()); - $this->assertSame('First item', $item->getName()); - $this->assertSame('pt', $item->getLocale()); - $this->assertSame('v2', $item->getVersion()); - - $query = $this->queryFromLastRequest($client); - - $this->assertSame('/items/1', $client->getLastRequest()->getUri()->getPath()); - $this->assertSame('test-key', $query['api_key']); - $this->assertSame('pt', $query['locale']); - $this->assertSame('v2', $query['version']); - } - - public function testItemCanBeMappedToEnvelope(): void - { - $client = $this->mockClient(new Response(status: 202, body: self::ITEM_RESPONSE)); - - $api = new SimpleApi('test-key'); - $api->setup()->client($client); - - $response = $api->items()->findResponse(1); - - $this->assertInstanceOf(SimpleResponse::class, $response); - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('en', $response->getLocale()); - $this->assertSame(1, $response->getEntity()->getId()); - $this->assertSame('en', $response->getEntity()->getLocale()); - $this->assertSame('v1', $response->getEntity()->getVersion()); - } - - public function testItemsCanBeMappedToCollection(): void - { - $client = $this->mockClient(new Response(body: '{ - "data": [ - {"id": 1, "name": "First item"}, - {"id": 2, "name": "Second item"} - ] - }')); - - $api = new SimpleApi('test-key', ['locale' => 'pt']); - $api->setup()->client($client); - - $items = $api->items()->all(); - - $this->assertContainsOnlyInstancesOf(SimpleEntity::class, $items); - $this->assertSame('First item', $items[0]->getName()); - $this->assertSame('Second item', $items[1]->getName()); - $this->assertSame('pt', $items[0]->getLocale()); - - $query = $this->queryFromLastRequest($client); - - $this->assertSame('/items', $client->getLastRequest()->getUri()->getPath()); - $this->assertSame('test-key', $query['api_key']); - } -} From 4d407525e732cedbf5f1938c0b5ef8b38b1c92e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:03:14 +0100 Subject: [PATCH 59/88] docs(cache): clarify endpoint cache overrides --- docs/05-resources.md | 2 ++ docs/09-cache.md | 48 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/docs/05-resources.md b/docs/05-resources.md index 58baf0c..64640fc 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -148,6 +148,8 @@ $users = $api ->all(); ``` +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. ## Navigation diff --git a/docs/09-cache.md b/docs/09-cache.md index 62296cf..1297c61 100644 --- a/docs/09-cache.md +++ b/docs/09-cache.md @@ -39,17 +39,28 @@ $this->cache($pool)->responseCacheDirectives(['max-age']); Sets the response cache directives respected by the cache plugin. -## Resource And Endpoint Overrides +## Cache Layers -SDK users can override cache behavior for one resource chain with `withCache()`: +Cache has three layers: + +```text +API cache config < endpoint cache defaults < resource withCache override +``` + +The API cache config is required because it provides the PSR-6 pool. Endpoint defaults and resource overrides only adjust an already configured cache builder. ```php -$fixtures = $api - ->fixtures() - ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) - ->live(); +$api->setup()->cache($pool); ``` +Use each layer for a different job: + +- API cache config: global cache pool and fallback defaults. +- Endpoint cache defaults: SDK-author intent for a specific endpoint. +- Resource `withCache()` override: SDK-user override for one resource chain. + +## Endpoint Defaults + SDK authors can set endpoint-specific cache defaults on the endpoint builder: ```php @@ -63,18 +74,33 @@ public function live(): FixtureCollection } ``` -Cache overrides require API-level cache configuration, because the global cache configuration provides the PSR-6 pool: +This is useful when the SDK author knows that one endpoint should behave differently from the global default. For example, realtime endpoints may default to a short TTL while stable lookup endpoints may default to a longer TTL. + +Endpoint defaults do not mutate the API cache builder and do not affect later requests. + +## 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. ```php -$api->setup()->cache($pool); +$fixtures = $api + ->fixtures() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->live(); ``` -Cache configuration is merged in this order: +`withCache()` is intentionally on the resource chain instead of every endpoint method argument. That keeps endpoint signatures focused on API-specific parameters while still giving SDK users a clear override path. -```text -API cache config < endpoint cache defaults < resource withCache override +## Missing Global Cache + +Endpoint defaults and resource overrides require global cache configuration: + +```php +$api->setup()->cache($pool); ``` +If an endpoint or resource override is used without global cache configuration, the request fails because there is no PSR-6 pool to clone and adjust. + ## Internal Order The cache plugin runs at priority `20`, after authentication and before the logger plugin. From 4225d45dedabe61389a414a19c65844cda27bf70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:12:08 +0100 Subject: [PATCH 60/88] test(resource): cover sdk-specific resource chains --- docs/04-resource-authoring.md | 48 +++++++++++++++++------------- tests/Fixture/UserResource.php | 11 +++++++ tests/Integration/ResourceTest.php | 28 +++++++++++++++++ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 2460734..d41115e 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -327,41 +327,47 @@ final class UserResponse implements ResponseEnvelopeInterface } ``` -## API-Specific Traits +## API-Specific Resource Chains -Keep API-specific vocabulary out of the base package. Add it in SDK packages through traits or API-specific base resources. +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. -For example, an SDK can add includes without making the generic package know what an include is: +For example, an SDK can expose a status filter without making the generic package know what a status filter is: ```php -use ProgrammatorDev\Api\Endpoint; - -trait HasIncludes +final class UserResource extends Resource { - protected function applyIncludes(Endpoint $endpoint, array $includes): Endpoint - { - return $endpoint->query('include', implode(';', $includes)); - } -} -``` + private ?string $status = null; -Then use it in that SDK's resources: + public function withStatus(string $status): static + { + $clone = clone $this; + $clone->status = $status; -```php -final class FixtureResource extends Resource -{ - use HasIncludes; + return $clone; + } - public function find(int $id, array $includes = []): FixtureResponse + public function all(): array { return $this - ->applyIncludes($this->endpoint(), $includes) - ->get('/fixtures/{id}', ['id' => $id]) - ->envelope(FixtureResponse::class); + ->endpoint() + ->query('status', $this->status) + ->get('/users') + ->collection(User::class, key: 'data'); } } ``` +SDK users get a fluent API-specific chain: + +```php +$users = $api + ->users() + ->withStatus('active') + ->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. + ## Navigation - Previous: [API](03-api.md) diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index 47489d2..fc89d35 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -8,6 +8,16 @@ class UserResource extends Resource { + private ?string $status = null; + + public function withStatus(string $status): static + { + $clone = clone $this; + $clone->status = $status; + + return $clone; + } + public function sendWithVerb(string $verb): void { match ($verb) { @@ -66,6 +76,7 @@ public function all(): array { return $this ->endpoint() + ->query('status', $this->status) ->get('/users') ->collection(User::class, key: 'data'); } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index c764d46..8e86127 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -146,6 +146,34 @@ public function testEndpointOptionsDoNotLeakBetweenResourceCalls(): void $this->assertSame('https://api.example.com/users/2?locale=en', (string) $requests[1]->getUri()); } + public function testSdkSpecificResourceChainCanSetQueryOptions(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"}]}')); + + $this->api + ->users() + ->withStatus('active') + ->all(); + + $this->assertSame('https://api.example.com/users?locale=en&status=active', (string) $this->client->getLastRequest()->getUri()); + } + + public function testSdkSpecificResourceChainDoesNotLeakBetweenResourceCalls(): 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(); + + $users->withStatus('active')->all(); + $users->all(); + + $requests = $this->client->getRequests(); + + $this->assertSame('https://api.example.com/users?locale=en&status=active', (string) $requests[0]->getUri()); + $this->assertSame('https://api.example.com/users?locale=en', (string) $requests[1]->getUri()); + } + public function testEndpointQueryOverridesGlobalDefaults(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); From df3b3e4439827f93a4da6f9e26b75125facd3ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:20:43 +0100 Subject: [PATCH 61/88] test(response): cover custom decoder pipeline --- docs/v3-architecture-plan.md | 11 +++++------ src/Builder/ErrorBuilder.php | 8 +++++++- tests/Fixture/PlainApi.php | 11 +++++++++++ tests/Integration/ResponseDecodingTest.php | 19 +++++++++++++++++++ tests/Unit/Builder/ErrorBuilderTest.php | 11 +++++++++++ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md index 66e0aaa..3593b94 100644 --- a/docs/v3-architecture-plan.md +++ b/docs/v3-architecture-plan.md @@ -246,12 +246,12 @@ Current hook capabilities: These capabilities remain through first-class APIs: -- JSON decoding. +- Response decoding. - Error mapping. - Request hooks. - Response hooks. -Decision: v3 replaces the Symfony EventDispatcher dependency with a smaller request/response pipeline. The pipeline supports request hooks and response hooks, while common features are first-class fluent APIs. +Decision: v3 replaces the Symfony EventDispatcher dependency with a smaller request/response pipeline. The pipeline supports request hooks and response hooks, while common features are first-class fluent APIs. Response decoding replaces the earlier idea of using transform hooks for common body parsing. Hooks should receive lightweight context objects rather than long argument lists: @@ -287,11 +287,10 @@ afterResponse hooks decode body create Response wrapper error handling -transform hooks return Response ``` -Error handling should run before transform hooks so API-specific error mapping sees the original decoded API response shape. +Error handling runs after response decoding so API-specific error mapping can inspect the decoded API response shape. ### Helpers and Test Utilities @@ -365,7 +364,7 @@ The v3 test utilities should focus on helping SDK authors test resources, respon - Full URL paths should continue to override the configured base URL. - Invalid JSON should throw when JSON decoding is enabled. - Empty JSON response bodies should decode to `null` without throwing. -- Pipeline order should be request hooks, send, response hooks, decode, response wrapper, errors, transforms, return. +- Pipeline order should be request hooks, send, response hooks, decode, response wrapper, errors, return. - Hooks should return replacement objects/data. Returning `null` means no change. - Error handling should support both status maps and custom callbacks. - Error callbacks should receive an `ErrorContext` object and return a `Throwable` when matched or `null` when not matched. @@ -521,7 +520,7 @@ Before tagging v3: - [x] Plugin support. - [x] Request hooks. - [x] Response hooks. -- [x] Response content transformation. +- [x] Response decoding and custom decoder. - [x] Query defaults. - [x] Header defaults. - [x] Base URL handling. diff --git a/src/Builder/ErrorBuilder.php b/src/Builder/ErrorBuilder.php index bc0576e..902671c 100644 --- a/src/Builder/ErrorBuilder.php +++ b/src/Builder/ErrorBuilder.php @@ -63,7 +63,13 @@ public function throwIfMatched(ErrorContext $context): void } if ($handler !== null) { - throw $handler($context); + $throwable = $handler($context); + + if (! $throwable instanceof \Throwable) { + throw new \UnexpectedValueException('Status error handler must return a Throwable.'); + } + + throw $throwable; } foreach ($this->handlers as $handler) { diff --git a/tests/Fixture/PlainApi.php b/tests/Fixture/PlainApi.php index 55fcb30..1e05acf 100644 --- a/tests/Fixture/PlainApi.php +++ b/tests/Fixture/PlainApi.php @@ -4,6 +4,7 @@ use Http\Mock\Client; use ProgrammatorDev\Api\Api; +use Psr\Http\Message\ResponseInterface; class PlainApi extends Api { @@ -15,6 +16,16 @@ public function __construct(Client $client) $this->baseUrl('https://api.example.com'); } + /** + * @param callable(ResponseInterface): mixed $decoder + */ + public function decodeWith(callable $decoder): self + { + $this->responses()->custom($decoder); + + return $this; + } + public function raw(): RawResource { return $this->resource(RawResource::class); diff --git a/tests/Integration/ResponseDecodingTest.php b/tests/Integration/ResponseDecodingTest.php index c43dbac..d8f0ca3 100644 --- a/tests/Integration/ResponseDecodingTest.php +++ b/tests/Integration/ResponseDecodingTest.php @@ -7,6 +7,7 @@ use ProgrammatorDev\Api\Test\Fixture\PlainApi; use ProgrammatorDev\Api\Test\Fixture\XmlApi; use ProgrammatorDev\Api\Test\Support\AbstractTestCase; +use Psr\Http\Message\ResponseInterface; use SimpleXMLElement; class ResponseDecodingTest extends AbstractTestCase @@ -75,4 +76,22 @@ public function testInvalidXmlThrowsWhenXmlDecodingIsEnabled(): void (new XmlApi($client))->raw()->fetch(); } + + public function testCustomDecoderRunsThroughApiResourcePipeline(): void + { + $client = $this->mockClient(new Response(status: 202, body: 'accepted')); + + $response = (new PlainApi($client)) + ->decodeWith(fn (ResponseInterface $response): array => [ + 'status' => $response->getStatusCode(), + 'body' => (string) $response->getBody(), + ]) + ->raw() + ->fetch(); + + $this->assertSame([ + 'status' => 202, + 'body' => 'accepted', + ], $response->data()); + } } diff --git a/tests/Unit/Builder/ErrorBuilderTest.php b/tests/Unit/Builder/ErrorBuilderTest.php index f715dfd..05e52f2 100644 --- a/tests/Unit/Builder/ErrorBuilderTest.php +++ b/tests/Unit/Builder/ErrorBuilderTest.php @@ -69,6 +69,17 @@ public function testStatusHandlerRequiresThrowableClass(): void $builder->status(404, \stdClass::class); } + public function testStatusHandlerMustReturnThrowable(): void + { + $builder = new ErrorBuilder(); + $builder->status(404, fn(): string => 'invalid'); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Status error handler must return a Throwable.'); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + public function testCustomHandlerThrowsWhenMatched(): void { $builder = new ErrorBuilder(); From d0e131b8a12db295b083c48cf089c3ee9150fc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:23:02 +0100 Subject: [PATCH 62/88] docs: remove stale v3 guide wording --- README.md | 4 ++-- docs/00-index.md | 4 ++-- docs/01-getting-started.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d80a3aa..e3d679d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Getting Started](docs/01-getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. - [Design Approach](docs/02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. - [API](docs/03-api.md): SDK facade setup methods, configuration, and extension points. -- [Resource Authoring](docs/04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. +- [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. @@ -41,4 +41,4 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [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. -- [API Reference](docs/13-api-reference.md): current v3 authoring methods and contracts. +- [API Reference](docs/13-api-reference.md): authoring methods and contracts. diff --git a/docs/00-index.md b/docs/00-index.md index 16326e1..28e949e 100644 --- a/docs/00-index.md +++ b/docs/00-index.md @@ -32,7 +32,7 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [Getting Started](01-getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. - [Design Approach](02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. - [API](03-api.md): SDK facade setup methods, configuration, and extension points. -- [Resource Authoring](04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific traits. +- [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. @@ -41,7 +41,7 @@ SDK packages should also require or suggest concrete PSR-18 and PSR-17 implement - [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. -- [API Reference](13-api-reference.md): current v3 authoring methods and contracts. +- [API Reference](13-api-reference.md): authoring methods and contracts. ## Navigation diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index b36fb0c..922ac8d 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -1,6 +1,6 @@ # Getting Started -These examples describe the work-in-progress `v3.0` authoring API. +These examples show the current SDK authoring API. ## Install From 730365f9ea4304363d1b1ade94b6074f687b2b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:26:42 +0100 Subject: [PATCH 63/88] style: tidy builder formatting and endpoint docblocks --- src/Builder/ClientBuilder.php | 3 +-- src/Builder/LoggerBuilder.php | 3 +-- src/Endpoint.php | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/Builder/ClientBuilder.php b/src/Builder/ClientBuilder.php index 1cff60f..87e392e 100644 --- a/src/Builder/ClientBuilder.php +++ b/src/Builder/ClientBuilder.php @@ -16,8 +16,7 @@ public function __construct( private ?ClientInterface $client = null, private ?RequestFactoryInterface $requestFactory = null, private ?StreamFactoryInterface $streamFactory = null - ) - { + ) { $this->client ??= Psr18ClientDiscovery::find(); $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); diff --git a/src/Builder/LoggerBuilder.php b/src/Builder/LoggerBuilder.php index 02602a0..6d679f3 100644 --- a/src/Builder/LoggerBuilder.php +++ b/src/Builder/LoggerBuilder.php @@ -12,8 +12,7 @@ class LoggerBuilder public function __construct( private LoggerInterface $logger, ?Formatter $formatter = null - ) - { + ) { $this->formatter = $formatter ?: new Formatter\SimpleFormatter(); } diff --git a/src/Endpoint.php b/src/Endpoint.php index e2d100d..fe7858e 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -30,6 +30,9 @@ public function cache(callable $configure): static ); } + /** + * @throws \JsonException + */ public function json(array $data): static { return $this @@ -44,6 +47,9 @@ public function form(array $data): static ->body(http_build_query($data)); } + /** + * @throws \InvalidArgumentException + */ public function body(mixed $body): static { if (is_array($body)) { @@ -77,51 +83,81 @@ public function headers(array $headers): static return $this->withOptions($this->options->withHeaders($headers)); } + /** + * @throws \Throwable + */ public function get(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::GET, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function post(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::POST, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function put(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::PUT, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function patch(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::PATCH, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function delete(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::DELETE, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function head(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::HEAD, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function options(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::OPTIONS, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function connect(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::CONNECT, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ public function trace(string $path, array $pathParams = [], array $query = []): Response { return $this->send(Method::TRACE, $path, $pathParams, $query); } + /** + * @throws \Throwable + */ private function send(string $method, string $path, array $pathParams = [], array $query = []): Response { return $this->api->send( From e263ae0095a211acc585001aacf5a930d4fd206e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:29:43 +0100 Subject: [PATCH 64/88] style: document pipeline edge cases --- src/Http/Transport.php | 4 ++++ src/Request/PipelineOptions.php | 6 ++++++ src/Response/ResponseDecoder.php | 2 ++ 3 files changed, 12 insertions(+) diff --git a/src/Http/Transport.php b/src/Http/Transport.php index ca81a31..f0e3319 100644 --- a/src/Http/Transport.php +++ b/src/Http/Transport.php @@ -99,6 +99,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. $plugins->add( plugin: new ContentTypePlugin(), priority: self::CONTENT_TYPE_PLUGIN_PRIORITY @@ -145,6 +147,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. $cacheBuilder = clone $this->cacheBuilder; $pipelineOptions->applyTo(PipelineOption::CACHE, $cacheBuilder); diff --git a/src/Request/PipelineOptions.php b/src/Request/PipelineOptions.php index 7ccf839..2d1d0b9 100644 --- a/src/Request/PipelineOptions.php +++ b/src/Request/PipelineOptions.php @@ -2,6 +2,12 @@ namespace ProgrammatorDev\Api\Request; +/** + * Stores request-local pipeline configuration. + * + * Defaults are SDK-author intent, overrides are SDK-user intent. They are applied + * in that order so resource-chain overrides can win without mutating API globals. + */ class PipelineOptions { /** diff --git a/src/Response/ResponseDecoder.php b/src/Response/ResponseDecoder.php index 1c8a4e4..4c0a583 100644 --- a/src/Response/ResponseDecoder.php +++ b/src/Response/ResponseDecoder.php @@ -46,6 +46,8 @@ private function decodeXml(string $contents): ?SimpleXMLElement return null; } + // libxml error handling is global, so restore the previous setting after + // parsing to avoid changing behavior for calling applications. $previous = libxml_use_internal_errors(true); libxml_clear_errors(); From 7ba886f82c55777f66e7ab97ea9dc3c0fc8ecb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:40:26 +0100 Subject: [PATCH 65/88] chore: clean release metadata --- composer.json | 8 +- docs/v3-architecture-plan.md | 540 ----------------------------------- 2 files changed, 2 insertions(+), 546 deletions(-) delete mode 100644 docs/v3-architecture-plan.md diff --git a/composer.json b/composer.json index ac39c49..818d9fd 100644 --- a/composer.json +++ b/composer.json @@ -22,9 +22,9 @@ "php-http/message": "^1.16", "psr/cache": "^2.0|^3.0", "psr/http-client": "^1.0", - "psr/http-client-implementation": "*", + "psr/http-client-implementation": "^1.0", "psr/http-factory": "^1.1", - "psr/http-factory-implementation": "*", + "psr/http-factory-implementation": "^1.0", "psr/log": "^2.0|^3.0" }, "require-dev": { @@ -36,10 +36,6 @@ "symfony/http-client": "^6.4|^7.4|^8.0", "symfony/var-dumper": "^6.4|^7.4|^8.0" }, - "provide": { - "psr/http-client-implementation": "1.0", - "psr/http-factory-implementation": "1.0" - }, "autoload": { "psr-4": { "ProgrammatorDev\\Api\\": "src/" diff --git a/docs/v3-architecture-plan.md b/docs/v3-architecture-plan.md deleted file mode 100644 index 3593b94..0000000 --- a/docs/v3-architecture-plan.md +++ /dev/null @@ -1,540 +0,0 @@ -# v3 Architecture Plan - -## Goal - -`v3.0` should make this package easier and more enjoyable for SDK authors while preserving the power of `v2.x`. - -The target experience is fluent and compact: - -```php -final class UserResource extends Resource -{ - public function find(int $id): User - { - return $this - ->endpoint() - ->get('/users/{id}', ['id' => $id]) - ->entity(User::class); - } -} -``` - -SDK users should interact with purpose-built resources and entities, not raw request primitives: - -```php -$user = $api->users()->find(1); -``` - -## Core Concepts - -### `Api` - -The SDK facade and configuration surface. - -Responsibilities: - -- Configure base URL. -- Configure authentication. -- Configure global SDK options through a generic config bag. -- Configure global query and header defaults. -- Configure PSR clients and factories. -- Configure cache, logger, plugins, request hooks, response hooks, decoding, and errors. -- Expose resources through a protected/public SDK method pattern. - -Non-goals: - -- It should not require final SDK users to call raw HTTP request methods. -- It should not accumulate API-specific query concepts like `include`, `select`, `filter`, or pagination. - -`Api` should be abstract. It is a base class for concrete SDKs, not something users instantiate directly. It does not need to force subclasses to implement an abstract method in the first phase. - -`config()` should always return the config bag. When values or defaults are provided, it merges defaults first and explicit values second: - -```php -$this->config(['timezone' => 'UTC'], defaults: ['timezone' => 'Europe/Lisbon']); -$timezone = $this->config()->get('timezone'); -``` - -### `Resource` - -The base class for endpoint groups. - -Responsibilities: - -- Provide `endpoint()` as the SDK-author request builder entrypoint. -- Hold immutable resource-chain infrastructure overrides such as `withCache()`. -- Return a fresh resource instance by default when created through `Api::resource()`. -- Keep SDK-user autocomplete focused on domain methods and explicit infrastructure overrides. - -Non-goals: - -- It should not know about API-specific concepts. -- API-specific packages should add domain vocabulary through their own base resources or traits. -- Resource instance caching is not part of the first v3 slice. This is unrelated to PSR-6 HTTP response caching, which remains a feature parity requirement. - -### `RequestOptions` - -Immutable request state used internally by `Endpoint`. - -Responsibilities: - -- Store endpoint-local query parameters. -- Store endpoint-local headers. -- Store body/payload options when needed. -- Merge cleanly with API defaults during request execution. - -This avoids cloning and mutating the whole API instance for endpoint-specific request options. - -Endpoint-local options should be configured fluently before calling the HTTP method: - -```php -return $this - ->endpoint() - ->query('active', true) - ->get('/users/{id}', ['id' => $id]) - ->entity(User::class); -``` - -The path remains an argument of `get`, `post`, `put`, `patch`, and `delete`. Query and header options are configured through fluent endpoint methods. - -Query merge order: - -```text -global API defaults < endpoint options < endpoint method query argument -``` - -Builder-backed features can follow the same shape when endpoint-specific behavior is useful. - -API-level builders configure global defaults: - -```php -$api->setup()->cache($pool)->defaultTtl(3600); -``` - -Request-local overrides should live on the pending request/resource flow instead of mutating the API-level builder: - -```php -return $this - ->endpoint() - ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(300)) - ->get('/weather') - ->entity(CurrentWeather::class); -``` - -This keeps one SDK instance safe to reuse while still letting SDK authors expose endpoint-specific cache, logger, auth, plugin, or hook behavior where it makes sense. Do this one builder area at a time, starting only when there is a concrete feature need. - -Resource constructors may remain public. SDK authors should usually expose resources through `Api::resource()`, but direct construction is useful for testing and advanced use. - -### `Response` - -Wrapper around decoded data and the raw PSR response. - -Responsibilities: - -- Expose decoded data. -- Expose raw PSR response when needed. -- Map data to entities. -- Map list data to collections. -- Preserve response metadata when APIs return envelopes. -- Carry response context, including SDK config, for entity and envelope hydration. - -Custom envelopes should implement: - -```php -interface ResponseEnvelopeInterface -{ - public static function fromResponse(Response $response, ?Context $context = null): static; -} -``` - -`Response::envelope()` should require `ResponseEnvelopeInterface`. - -The wrapper should be named `Response`, not `ApiResponse`. PSR responses are referenced through `ResponseInterface`, so the shorter package name is acceptable and keeps SDK authoring readable. - -SDK authors may choose whether endpoint methods return entities directly or response envelope classes: - -```php -public function find(int $id): User -{ - return $this->get('/users/{id}', ['id' => $id])->entity(User::class, key: 'data'); -} - -public function findWithMeta(int $id): UserResponse -{ - return $this->get('/users/{id}', ['id' => $id])->envelope(UserResponse::class); -} -``` - -### Entity Interface - -Required contract for typed response objects used by `Response::entity()` and `Response::collection()`. - -Responsibilities: - -- Provide a simple convention for hydrating typed objects. -- Keep entities as data/value objects by default. -- Make entity mapping requirements explicit for SDK authors. - -Non-goals: - -- No lazy loading in the first phase. -- No hidden network calls from entity getters. - -Proposed contract: - -```php -interface EntityInterface -{ - public static function fromArray(array $data, ?Context $context = null): static; -} -``` - -`Context` should provide access to SDK config without injecting the full `Api` into entities or response envelopes. - -Start with a minimal context API. Add richer access only when implementation needs it. - -`toArray()` is not required for v3 entity mapping. It can be added by individual SDK entities or a future optional interface if serialization becomes a real package concern. - -## v2 Public Surface Inventory - -### `Api` - -Current public methods: - -- `client` -- `logger` -- `plugins` -- `cache` -- `config` - -Original v2 observations: - -- The v2 `Api` class is easy to extend but exposes low-level request execution directly. -- SDK packages used `request` from resources. -- Defaults were global to the API instance, which made resource-level fluent options awkward. -- JSON decoding and error handling were commonly implemented through listeners. -- Plugin configuration was automatic inside `request`, which duplicated responsibilities and made request flow harder to reason about. -- Symfony EventDispatcher provided flexibility, but common behavior like JSON decoding and error mapping should not require event listeners in v3. - -### Builders - -Current public builder classes: - -- `ClientBuilder` -- `PluginBuilder` -- `CacheBuilder` -- `LoggerBuilder` - -Current capabilities: - -- PSR-18 client discovery and injection. -- PSR-17 request and stream factory discovery and injection. -- Plugin registration by priority through `PluginBuilder` / `Api::plugins()`. -- PSR-6 cache configuration. -- PSR-3 logger and formatter configuration. - -These capabilities must remain available in v3. - -HTTPlug's `PluginClientBuilder` already supports priority ordering and multiple plugins at the same priority. v3 should use that behavior directly or mirror it closely. The current v2 `ClientBuilder` stores plugins as `[priority => plugin]`, which means plugins with the same priority overwrite each other. - -### Hooks - -Current hook capabilities: - -- Mutate request before sending. -- Mutate response after sending. - -These capabilities remain through first-class APIs: - -- Response decoding. -- Error mapping. -- Request hooks. -- Response hooks. - -Decision: v3 replaces the Symfony EventDispatcher dependency with a smaller request/response pipeline. The pipeline supports request hooks and response hooks, while common features are first-class fluent APIs. Response decoding replaces the earlier idea of using transform hooks for common body parsing. - -Hooks should receive lightweight context objects rather than long argument lists: - -```php -$this->hooks()->beforeRequest( - fn (RequestContext $context) => $context->request() -); - -$this->hooks()->afterResponse( - fn (ResponseContext $context) => $context->response() -); -``` - -Request/response contexts should expose the request, response where applicable, and SDK config. - -Hooks should use return-object semantics: - -```php -$this->hooks()->beforeRequest( - fn (RequestContext $context) => $context->request()->withHeader('X-Trace-Id', 'abc') -); -``` - -The returned object replaces the current request/response/data. Returning `null` means no change. This matches PSR-7 immutability and avoids mutable event/context objects. - -Initial pipeline order: - -```text -create request -beforeRequest hooks -send request -afterResponse hooks -decode body -create Response wrapper -error handling -return Response -``` - -Error handling runs after response decoding so API-specific error mapping can inspect the decoded API response shape. - -### Helpers and Test Utilities - -Current helpers: - -- `UrlHelper::join` -- `UrlHelper::isAbsoluteUrl` -- `Method` constants. - -Current test utilities: - -- `AbstractTestCase` -- `MockResponse` -- `TestApi` - -The v3 test utilities should focus on helping SDK authors test resources, responses, and entity mapping. - -## v3 Replacement Map - -| v2 capability | Proposed v3 shape | -| --- | --- | -| Public `Api::request` | Removed; public `Api::send()` delegates HTTP mechanics to internal `Transport` | -| `Api::buildPath` | Path parameter replacement inside `Resource`/transport `get('/x/{id}', ['id' => $id])` | -| `setBaseUrl` / `getBaseUrl` | Fluent `baseUrl(...)`, optional getter only if useful | -| SDK-specific global options | Generic config bag exposed to resources/responses/entities through context | -| Query/header defaults | Fluent `defaultQueries(...)`, `defaultHeaders(...)` | -| Per-resource query options | Removed as a generic base feature; SDK authors should use explicit method arguments or API-specific state and apply options through `Endpoint` | -| `setAuthentication` | Fluent `auth()` helper wrapping HTTPlug authentication plus low-level authentication injection | -| Client/factory injection | Keep builder-style or fluent config methods | -| Plugins | Use HTTPlug `PluginClientBuilder`-style priority handling; preserve multiple plugins at the same priority | -| Cache | Keep PSR-6 support, likely through fluent `cache(...)` | -| Logger | Keep PSR-3 support, likely through fluent `logger(...)` | -| `ResponseContentsEvent` for JSON | Removed; first-class `responses()->json()` | -| Post-response listener for errors | First-class status and callback-based error mapping, while preserving hooks | -| Raw response body return | `Response::data()` or `Response::raw()` depending on configuration | -| Manual entity construction in resources | `Response::entity(...)`, `Response::collection(...)`, and `Response::envelope(...)` helpers | - -## Decisions - -- Resource instances should not be cached by default. -- PSR-6 HTTP response caching remains a separate feature. -- `Api` should be abstract. -- v3 should remove old v2 public low-level methods instead of keeping deprecated aliases. -- SDK-wide options should be stored in a generic config bag. -- SDK config should be available through context objects, not by injecting `Api` into entities. -- `Response::entity()` and `Response::collection()` should require classes that implement `EntityInterface`. -- `Response::envelope()` should support API-specific response envelope classes such as item, collection, metadata, and pagination responses. -- `Response::envelope()` should require a `ResponseEnvelopeInterface` contract with `fromResponse(Response $response, ?Context $context = null)`. -- `Response::collection()` should return a plain array by default. -- Do not add a generic collection object in the first phase. A future `collect()` helper can be considered later if arrays become limiting. -- Symfony EventDispatcher has been replaced with a smaller request/response pipeline. -- v3-native hooks are represented by `HookBuilder`, `RequestContext`, and `ResponseContext`. -- Response body decoding is represented by `ResponseDecoder` and `ResponseFormat`; transport returns raw PSR responses and does not decode. Common formats use `raw()`, `json()`, and `xml()`, while custom response decoding uses `custom()`. -- Method constants are not central to v3 because resources expose `get`, `post`, `put`, `patch`, and `delete` helpers. -- Prefer fluent configuration over public getters. -- Use HTTPlug `PluginClientBuilder` behavior for plugin priority ordering and same-priority plugin preservation. -- Do not keep generic query/header modifiers on `Resource`; `Endpoint` owns request-local options. -- `get`, `post`, `put`, `patch`, and `delete` should execute immediately. -- SDK authors choose whether resource methods return entities directly or custom response envelopes. -- Resource constructors may remain public. -- Use PHPDoc generics where useful, especially for `Api::resource()`, `Response::entity()`, `Response::collection()`, and `Response::envelope()`. -- No reset methods are needed for generic query/header customization because those methods are not part of the base `Resource` API. -- Merge order should be global defaults, then endpoint options, then endpoint method query arguments. -- Client configuration is global API setup only. Do not add `Resource::client()`. -- Request-local cache now uses `PipelineOptions` so endpoint defaults and resource-chain overrides can be layered over the API-level cache builder. Defer request-local plugins, logger, hooks, and similar pipeline features until there is a concrete need, and reuse the same pipeline option shape where it fits. -- Header names should not be normalized manually. -- Path parameters should be encoded with `rawurlencode`. -- Query strings should use `http_build_query(..., PHP_QUERY_RFC3986)`. -- Null query values should be omitted by default. -- Boolean query values should use standard `http_build_query` behavior. -- Full URL paths should continue to override the configured base URL. -- Invalid JSON should throw when JSON decoding is enabled. -- Empty JSON response bodies should decode to `null` without throwing. -- Pipeline order should be request hooks, send, response hooks, decode, response wrapper, errors, return. -- Hooks should return replacement objects/data. Returning `null` means no change. -- Error handling should support both status maps and custom callbacks. -- Error callbacks should receive an `ErrorContext` object and return a `Throwable` when matched or `null` when not matched. -- Fluent auth helpers should wrap existing HTTPlug authentication objects. -- Fluent config should use grouped builders such as `auth()`, `responses()`, `errors()`, `plugins()`, `cache()`, `logger()`, and `hooks()`. -- `auth()` should mirror and wrap HTTPlug authentication behavior rather than inventing new authentication primitives. -- `Response::entity()` and `Response::collection()` should support an optional key for extracting entity data from decoded response envelopes. -- `Response::envelope()` should receive the full decoded `Response`, leaving envelope classes responsible for extracting their data. -- Response data access should stay simple in the first phase. No dot notation or nested key helpers. -- Request body helpers should be friendly for SDK authors while converting to PSR-7 streams internally. -- Resource body helpers should be fluent: `json()`, `form()`, and `body()`. -- Passing an array to `body()` should throw; SDK authors should choose `json()` or `form()` explicitly. -- `json()` should set `Content-Type: application/json`. -- `form()` should set `Content-Type: application/x-www-form-urlencoded`. -- `body(string|StreamInterface)` should not guess `Content-Type`. -- `responses()->json()` should decode all responses, including error responses. -- v3 should not throw for HTTP error status codes by default. SDK authors opt into error behavior through `errors()`. -- Main author-facing classes should stay in the root namespace: `Api`, `Resource`, `Response`, `EntityInterface`, and `ResponseEnvelopeInterface`. -- Internal/supporting classes can live in subnamespaces such as `Request`, `Context`, and `Builder`. -- Package exception classes can be decided as implementation needs emerge. -- Tests should use generic fake SDK fixtures, not downstream SDK names or classes. -- Keep PHP `>=8.1` for now. - -## Suggested v3 Authoring API - -Example SDK facade: - -```php -final class ExampleApi extends Api -{ - public function __construct(string $token) - { - parent::__construct(); - - $this - ->baseUrl('https://api.example.com') - ->auth()->bearer($token) - ->config(['timezone' => 'UTC']) - ->defaultQueries(['locale' => 'en']) - ->responses()->json(); - } - - public function users(): UserResource - { - return $this->resource(UserResource::class); - } -} -``` - -Example resource: - -```php -final class UserResource extends Resource -{ - public function all(): UserCollection - { - return $this - ->endpoint() - ->query('active', true) - ->get('/users') - ->envelope(UserCollection::class); - } - - public function find(int $id): User - { - return $this - ->endpoint() - ->get('/users/{id}', ['id' => $id]) - ->entity(User::class, key: 'data'); - } -} -``` - -Example custom response envelope: - -```php -final class FixtureResource extends Resource -{ - public function find(int $id): FixtureItem - { - return $this - ->endpoint() - ->get('/v3/football/fixtures/{id}', ['id' => $id]) - ->envelope(FixtureItem::class); - } -} -``` - -Example SDK-specific options: - -```php -trait IncludeTrait -{ - public function include(string ...$includes): static - { - return $this->query('include', implode(';', $includes)); - } -} -``` - -## Open Questions - -No blocking open questions remain for the first implementation phase. - -Future-phase questions should be answered when that phase starts, not before: - -- Exact hook method names and context details. -- Whether any public configuration getters are useful for testing or advanced extension. -- Whether `Method` remains as a tiny compatibility helper or is removed entirely. -- Whether `config()` ever supports nested keys. -- Whether a future `collect()` helper should return a small generic collection object. - -## First Implementation Slice - -1. Add fake SDK fixtures under tests. -2. Add `Resource`, `RequestOptions`, `Response`, and `EntityInterface`. -3. Add protected/fluent resource creation and endpoint request execution. -4. Prove endpoint flows with focused fake resources and mock PSR clients: - -```php -$user = $api->users()->find(1); -``` - -5. Add tests for path parameter replacement, endpoint query options, query merge order, and entity mapping. - -Do not add collection mapping, custom envelopes, SDK config, entity context, JSON decoding, errors, hooks, body helpers, auth, plugins, cache, or logger in the first slice. Preserve momentum by getting the authoring experience right first. - -## Phase Discipline - -Implement v3 incrementally. The plan is intentionally broad because v3 must remain feature complete with v2, but each implementation phase should stay narrow. - -1. Prove fluent resource authoring for GET requests and entity mapping. -2. Add collection and custom envelope mapping. -3. Add resource HTTP verb helpers. -4. Add resource body helpers. -5. Add SDK config and entity/response context. -6. Add JSON response decoding and error pipeline. -7. Add auth, plugins, cache, logger, and remaining PSR feature parity. -8. Update README and write `UPGRADE-3.0.md` once names and signatures are stable. - -Do not front-load advanced features before the simple SDK authoring path feels right. - -## Feature Parity Checklist - -Before tagging v3: - -- [x] PSR-18 client support. -- [x] PSR-17 request factory support. -- [x] PSR-17 stream factory support. -- [x] PSR-6 cache support. -- [x] PSR-3 logger support. -- [x] Authentication support. -- [x] Plugin support. -- [x] Request hooks. -- [x] Response hooks. -- [x] Response decoding and custom decoder. -- [x] Query defaults. -- [x] Header defaults. -- [x] Base URL handling. -- [x] Path parameter replacement. -- [x] Resource HTTP verb helpers. -- [x] Resource body helpers. -- [x] JSON response decoding. -- [x] Error mapping. -- [x] Entity mapping. -- [x] Collection mapping. -- [x] Custom response envelope mapping. -- [x] Entity context and SDK config access. -- [x] SDK author test fixtures. -- [ ] README update. -- [ ] `UPGRADE-3.0.md`. -- [x] Simple API proof. -- [ ] Complex API proof. From dfb1f51ea0c197686df6272c6421706bb94d7095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 7 Jun 2026 23:53:22 +0100 Subject: [PATCH 66/88] docs(http): clarify discovery requirements --- README.md | 8 ++++---- docs/00-index.md | 8 ++++---- docs/01-getting-started.md | 2 +- docs/08-http-client.md | 8 +++++--- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e3d679d..d7042e5 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ The practical guides below show how to build resources, map responses, and confi ## Requirements - PHP `>=8.1` -- A PSR-18 HTTP client implementation -- PSR-17 request and stream factory implementations +- PSR-18 HTTP client support +- PSR-17 request and stream factory support -The package can discover compatible HTTP clients and factories through PHP-HTTP discovery when implementations are installed. +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically from the supported virtual packages. ## Installation @@ -25,7 +25,7 @@ The package can discover compatible HTTP clients and factories through PHP-HTTP composer require programmatordev/php-api-sdk ``` -SDK packages should also require or suggest concrete PSR-18 and PSR-17 implementations suitable for their users. +SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementations when they want tighter control over the default HTTP stack. ## Guides diff --git a/docs/00-index.md b/docs/00-index.md index 28e949e..92b0ced 100644 --- a/docs/00-index.md +++ b/docs/00-index.md @@ -14,10 +14,10 @@ The practical guides below show how to build resources, map responses, and confi ## Requirements - PHP `>=8.1` -- A PSR-18 HTTP client implementation -- PSR-17 request and stream factory implementations +- PSR-18 HTTP client support +- PSR-17 request and stream factory support -The package can discover compatible HTTP clients and factories through PHP-HTTP discovery when implementations are installed. +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically from the supported virtual packages. ## Installation @@ -25,7 +25,7 @@ The package can discover compatible HTTP clients and factories through PHP-HTTP composer require programmatordev/php-api-sdk ``` -SDK packages should also require or suggest concrete PSR-18 and PSR-17 implementations suitable for their users. +SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementations when they want tighter control over the default HTTP stack. ## Guides diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 922ac8d..cd99af5 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -8,7 +8,7 @@ These examples show the current SDK authoring API. composer require programmatordev/php-api-sdk ``` -You also need compatible PSR-18 and PSR-17 implementations. The package uses PHP-HTTP discovery, so SDK packages can choose the implementations they want to require or suggest. +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically. SDK packages may still require or suggest concrete implementations when they want tighter control over the default HTTP stack. ## Create An API Class diff --git a/docs/08-http-client.md b/docs/08-http-client.md index 81741ee..5ce270d 100644 --- a/docs/08-http-client.md +++ b/docs/08-http-client.md @@ -2,7 +2,9 @@ The SDK uses a PSR-18 HTTP client to send requests and PSR-17 factories to create requests and streams. -If compatible implementations are installed, the package can discover them automatically through PHP-HTTP discovery. SDK authors can also provide concrete implementations explicitly. +By default, the package uses PHP-HTTP discovery. When the `php-http/discovery` Composer plugin is enabled, missing PSR-18 and PSR-17 implementations can be installed automatically from the supported virtual packages required by this package. + +SDK authors can still provide concrete implementations explicitly when a concrete SDK should control its default HTTP stack. ```php use Http\Discovery\Psr17FactoryDiscovery; @@ -16,10 +18,10 @@ $this ## SDK Author Defaults -SDK authors can configure the client and factories inside the API constructor when the SDK should control its defaults. +SDK authors can configure the client and factories inside the API constructor when discovery should not choose them automatically. ```php -use Programmatordev\ApiSdk\Api; +use ProgrammatorDev\Api\Api; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; From ac851b237c4c692a2151ae1d6fa2371eb8d13ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 12 Jun 2026 09:38:41 +0100 Subject: [PATCH 67/88] docs(readme): add project badges --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7042e5..7e5b6d4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Documentation +# PHP API SDK + +[![Latest Version](https://img.shields.io/github/release/programmatordev/php-api-sdk.svg?style=flat-square)](https://github.com/programmatordev/php-api-sdk/releases) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) +[![Tests](https://github.com/programmatordev/php-api-sdk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/php-api-sdk/actions/workflows/ci.yml?query=branch%3Amain) These docs describe how to create API SDKs with this package. From cefc6d9a65dbf45081e53d0b3a8e2434476de242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 12 Jun 2026 11:05:16 +0100 Subject: [PATCH 68/88] docs(upgrade): add v3 upgrade guide --- README.md | 4 + UPGRADE-3.0.md | 202 +++++++++++++++++++++++++++++++++++++++++++++++ docs/00-index.md | 4 + 3 files changed, 210 insertions(+) create mode 100644 UPGRADE-3.0.md diff --git a/README.md b/README.md index 7e5b6d4..e86d2f5 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,7 @@ SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementat - [Plugins](docs/11-plugins.md): configure HTTPlug middleware and priority ordering. - [Hooks](docs/12-hooks.md): run SDK-author callbacks around requests and responses. - [API Reference](docs/13-api-reference.md): authoring methods and contracts. + +## Upgrading + +Version 3.0 is a full architecture refresh. See [Upgrade to 3.0](UPGRADE-3.0.md) for the high-level changes. diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md new file mode 100644 index 0000000..faf2769 --- /dev/null +++ b/UPGRADE-3.0.md @@ -0,0 +1,202 @@ +# Upgrade to 3.0 + +Version 3.0 is a full architecture refresh. This is not a step-by-step migration guide because most SDKs should be reshaped around the new authoring model instead of mechanically replacing old calls. + +Use this document as a short summary of what changed and where to look when updating an SDK. + +## Resources Use Endpoint Builders + +Request helpers now live behind `endpoint()` inside resources. + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +``` + +Use endpoint modifiers for request-local state: + +```php +return $this + ->endpoint() + ->query('active', true) + ->header('X-Tenant', $tenant) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +See [Resource Authoring: Endpoint Requests](docs/04-resource-authoring.md#endpoint-requests), [Resource Authoring: Query And Headers](docs/04-resource-authoring.md#query-and-headers), and [Resources: Endpoint HTTP Methods](docs/05-resources.md#endpoint-http-methods) for details. + +## Responses Are First-Class + +Entities must implement `EntityInterface`: + +```php +public static function fromArray(array $data, ?Context $context = null): static; +``` + +Use response mapping helpers: + +```php +$response->entity(User::class); +$response->collection(User::class, key: 'data'); +$response->envelope(UserResponse::class); +``` + +Response envelopes must implement `ResponseEnvelopeInterface`. + +See [Responses: EntityInterface](docs/06-responses.md#entityinterface) and [Responses: ResponseEnvelopeInterface](docs/06-responses.md#responseenvelopeinterface) for details. + +## Config Replaces Ad Hoc Options + +SDK options should use `config()`: + +```php +$this->config($options, defaults: [ + 'timezone' => 'UTC', +]); +``` + +The same config is available to entities, response envelopes, and hooks through context objects. + +See [API](docs/03-api.md) and [Responses: Context](docs/06-responses.md#context) for details. + +## Decoding And Errors Are First-Class + +Response decoding is configured with `responses()`: + +```php +$this->responses()->json(); +$this->responses()->xml(); +$this->responses()->custom($decoder); +``` + +HTTP errors do not throw by default. Configure error handling explicitly: + +```php +$this->errors()->status(404, NotFoundException::class); +$this->errors()->when(fn (ErrorContext $context) => null); +``` + +See [API](docs/03-api.md) and [Responses](docs/06-responses.md) for details. + +## Infrastructure Uses Builders + +PSR-18 clients, PSR-17 factories, PSR-6 cache, PSR-3 logging, HTTPlug authentication, plugins, and hooks are still supported. In v3, they are configured through grouped builders instead of scattered low-level methods. + +```php +$this->auth()->bearer($token); +$this->cache($pool)->defaultTtl(3600); +$this->logger($logger); +$this->plugins()->add($plugin); +$this->hooks()->beforeRequest($hook); +$this->client($client)->requestFactory($requestFactory); +``` + +`plugins()` remains the right place for transport-level behavior. `hooks()` remains available for request and response lifecycle customization, but response decoding and error handling now have dedicated builders. + +Authentication is replaced by each `auth()` call unless you explicitly use `chain()`: + +```php +use Http\Message\Authentication\Bearer; +use Http\Message\Authentication\QueryParam; + +$this->auth()->chain( + new Bearer($token), + new QueryParam(['api_key' => $apiKey]), +); +``` + +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. + +## Defaults And Endpoint Overrides + +SDK authors can still configure request defaults: + +```php +$this->defaultHeaders(['Accept' => 'application/json']); +$this->defaultQueries($this->config()->only(['units', 'locale'])); +``` + +SDK authors can configure endpoint-specific cache defaults inside the endpoint chain: + +```php +use ProgrammatorDev\Api\Builder\CacheBuilder; + +return $this + ->endpoint() + ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->get('/live') + ->collection(Event::class, key: 'data'); +``` + +SDK users can override that cache behavior for one resource chain with `withCache()`: + +```php +$events = $api + ->events() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->live(); +``` + +Cache precedence is: + +```text +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. + +## Setup Is The Escape Hatch + +Most SDK-user customization now goes through `setup()`: + +```php +$api->setup()->client($client); +$api->setup()->plugins()->add($plugin); +$api->setup()->auth()->bearer($token); +``` + +SDK authors still configure defaults from the `Api` subclass with protected helpers such as `baseUrl()`, `defaultQueries()`, `auth()`, `responses()`, `errors()`, `cache()`, `logger()`, `plugins()`, and `hooks()`. + +See [API](docs/03-api.md) and [Design Approach: Escape Hatch](docs/02-design-approach.md#escape-hatch) for details. + +## Send Is Public + +`send()` is public as an advanced escape hatch. SDK users can call endpoints that are not modeled by the concrete SDK while still using the SDK's configured base URL, authentication, cache, plugins, hooks, decoding, and error handling. + +```php +$response = $api->send('GET', '/unmodeled-endpoint', queries: [ + 'page' => 1, +]); +``` + +See [API](docs/03-api.md) for details. + +## HTTP Client Discovery + +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically from the supported virtual packages. + +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. + +## API-Specific Behavior Belongs In SDKs + +API-specific options such as includes, filters, selects, or pagination should be implemented in concrete SDK resources, not in the base package. + +```php +$users = $api + ->users() + ->withStatus('active') + ->all(); +``` + +See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authoring.md#api-specific-resource-chains) for details. + +## Test Utilities Are Support Code + +The v3 test helpers are intended to support this package and SDK author tests. Concrete SDKs should prefer focused tests around their own resources, entities, response envelopes, fake clients, and API-specific fluent helpers. diff --git a/docs/00-index.md b/docs/00-index.md index 92b0ced..822a939 100644 --- a/docs/00-index.md +++ b/docs/00-index.md @@ -43,6 +43,10 @@ SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementat - [Hooks](12-hooks.md): run SDK-author callbacks around requests and responses. - [API Reference](13-api-reference.md): authoring methods and contracts. +## Upgrading + +Version 3.0 is a full architecture refresh. See [Upgrade to 3.0](../UPGRADE-3.0.md) for the high-level changes. + ## Navigation - Next: [Getting Started](01-getting-started.md) From d00724a7852b5ded5408d76adae35e0e5a760bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 16:01:25 +0100 Subject: [PATCH 69/88] refactor(endpoint): remove query argument from verbs --- docs/01-getting-started.md | 9 ++++++-- docs/04-resource-authoring.md | 7 ++++-- docs/05-resources.md | 3 ++- docs/06-responses.md | 2 ++ src/Endpoint.php | 40 +++++++++++++++++----------------- tests/Fixture/UserResource.php | 3 ++- 6 files changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index cd99af5..11b05e7 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -48,6 +48,8 @@ $user = $api->users()->find(1); Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `EntityInterface`. +`fromArray()` is the SDK author's mapping boundary: it defines how decoded API data becomes the entity. + ```php use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; @@ -114,10 +116,13 @@ Path parameters are passed as the second argument to the HTTP helper: $this->endpoint()->get('/users/{id}', ['id' => $id]); ``` -Endpoint-specific query parameters can be passed as the third argument: +Endpoint-specific query parameters are configured on the endpoint builder: ```php -$this->endpoint()->get('/users/{id}', ['id' => $id], ['locale' => 'pt']); +$this + ->endpoint() + ->query('locale', 'pt') + ->get('/users/{id}', ['id' => $id]); ``` SDK authors decide how SDK users customize requests. Often a method argument is enough: diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index d41115e..9f31d48 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -59,12 +59,13 @@ $endpoint->trace('/users'); Each helper executes the request immediately and returns a `Response` wrapper. -Endpoint-specific query parameters can be passed as the third argument: +Endpoint-specific query parameters are configured on the endpoint builder: ```php return $this ->endpoint() - ->get('/users/{id}', ['id' => $id], ['locale' => 'pt']) + ->query('locale', 'pt') + ->get('/users/{id}', ['id' => $id]) ->entity(User::class); ``` @@ -189,6 +190,8 @@ final class User implements EntityInterface } ``` +Treat `fromArray()` as the entity's mapping boundary. It keeps resource methods focused on requests and lets each entity own how decoded API payloads become typed PHP values. + Use the optional `key` argument when the object is nested inside an envelope: ```php diff --git a/docs/05-resources.md b/docs/05-resources.md index 64640fc..cf3c825 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -134,9 +134,10 @@ All helpers accept: ```php string $path array $pathParams = [] -array $query = [] ``` +Use `query()`, `queries()`, `header()`, and `headers()` to configure request-local query parameters and headers before calling the HTTP helper. + ## Resource Cache Overrides `withCache()` lets SDK users override cache behavior for one resource chain while keeping query, headers, body, and verbs inside `Endpoint`. diff --git a/docs/06-responses.md b/docs/06-responses.md index 124fc05..3d834af 100644 --- a/docs/06-responses.md +++ b/docs/06-responses.md @@ -75,6 +75,8 @@ Entities used by response mapping must implement: public static function fromArray(array $data, ?Context $context = null): static; ``` +`fromArray()` is the mapping boundary for an entity. The package passes decoded response data to it; the SDK author decides how payload keys become constructor arguments, value objects, or derived values. + ## `ResponseEnvelopeInterface` Response envelopes used by `Response::envelope()` must implement: diff --git a/src/Endpoint.php b/src/Endpoint.php index fe7858e..d264a05 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -86,85 +86,85 @@ public function headers(array $headers): static /** * @throws \Throwable */ - public function get(string $path, array $pathParams = [], array $query = []): Response + public function get(string $path, array $pathParams = []): Response { - return $this->send(Method::GET, $path, $pathParams, $query); + return $this->send(Method::GET, $path, $pathParams); } /** * @throws \Throwable */ - public function post(string $path, array $pathParams = [], array $query = []): Response + public function post(string $path, array $pathParams = []): Response { - return $this->send(Method::POST, $path, $pathParams, $query); + return $this->send(Method::POST, $path, $pathParams); } /** * @throws \Throwable */ - public function put(string $path, array $pathParams = [], array $query = []): Response + public function put(string $path, array $pathParams = []): Response { - return $this->send(Method::PUT, $path, $pathParams, $query); + return $this->send(Method::PUT, $path, $pathParams); } /** * @throws \Throwable */ - public function patch(string $path, array $pathParams = [], array $query = []): Response + public function patch(string $path, array $pathParams = []): Response { - return $this->send(Method::PATCH, $path, $pathParams, $query); + return $this->send(Method::PATCH, $path, $pathParams); } /** * @throws \Throwable */ - public function delete(string $path, array $pathParams = [], array $query = []): Response + public function delete(string $path, array $pathParams = []): Response { - return $this->send(Method::DELETE, $path, $pathParams, $query); + return $this->send(Method::DELETE, $path, $pathParams); } /** * @throws \Throwable */ - public function head(string $path, array $pathParams = [], array $query = []): Response + public function head(string $path, array $pathParams = []): Response { - return $this->send(Method::HEAD, $path, $pathParams, $query); + return $this->send(Method::HEAD, $path, $pathParams); } /** * @throws \Throwable */ - public function options(string $path, array $pathParams = [], array $query = []): Response + public function options(string $path, array $pathParams = []): Response { - return $this->send(Method::OPTIONS, $path, $pathParams, $query); + return $this->send(Method::OPTIONS, $path, $pathParams); } /** * @throws \Throwable */ - public function connect(string $path, array $pathParams = [], array $query = []): Response + public function connect(string $path, array $pathParams = []): Response { - return $this->send(Method::CONNECT, $path, $pathParams, $query); + return $this->send(Method::CONNECT, $path, $pathParams); } /** * @throws \Throwable */ - public function trace(string $path, array $pathParams = [], array $query = []): Response + public function trace(string $path, array $pathParams = []): Response { - return $this->send(Method::TRACE, $path, $pathParams, $query); + return $this->send(Method::TRACE, $path, $pathParams); } /** * @throws \Throwable */ - private function send(string $method, string $path, array $pathParams = [], array $query = []): Response + private function send(string $method, string $path, array $pathParams = []): Response { return $this->api->send( method: $method, path: $path, pathParams: $pathParams, - query: $this->options->withQueries($query)->getQuery(), + query: $this->options->getQuery(), headers: $this->options->getHeaders(), body: $this->options->getBody(), pipelineOptions: $this->pipelineOptions diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index fc89d35..de4b8fb 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -109,7 +109,8 @@ public function findWithEndpointLocale(int|string $id, string $locale): User { return $this ->endpoint() - ->get('/users/{id}', ['id' => $id], ['locale' => $locale]) + ->query('locale', $locale) + ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } From 04d42e0b354ee6051275b782b152172b979d8446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 16:16:28 +0100 Subject: [PATCH 70/88] docs: clarify getting started examples --- docs/01-getting-started.md | 18 ++++++++++++------ docs/02-design-approach.md | 7 ++++--- docs/08-http-client.md | 10 ++++++---- docs/12-hooks.md | 6 ++---- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 11b05e7..cf0ee47 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -155,15 +155,20 @@ use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; final class UserResponse implements ResponseEnvelopeInterface { public function __construct( - public readonly User $user, - public readonly int $statusCode, + /** @var User[] */ + public readonly array $users, + public readonly int $page, + public readonly int $totalPages, ) {} public static function fromResponse(Response $response, ?Context $context = null): static { + $data = $response->data(); + return new self( - user: $response->entity(User::class, key: 'data'), - statusCode: $response->raw()->getStatusCode(), + users: $response->collection(User::class, key: 'data'), + page: $data['pagination']['page'], + totalPages: $data['pagination']['total_pages'], ); } } @@ -172,11 +177,12 @@ final class UserResponse implements ResponseEnvelopeInterface Then return it from the resource: ```php -public function findWithMeta(int $id): UserResponse +public function all(int $page = 1): UserResponse { return $this ->endpoint() - ->get('/users/{id}', ['id' => $id]) + ->query('page', $page) + ->get('/users') ->envelope(UserResponse::class); } ``` diff --git a/docs/02-design-approach.md b/docs/02-design-approach.md index 8838e22..23431ce 100644 --- a/docs/02-design-approach.md +++ b/docs/02-design-approach.md @@ -18,9 +18,9 @@ final class ExampleApi extends Api { $this ->baseUrl('https://api.example.com') - ->defaultHeader('Accept', 'application/json') - ->responses() - ->json(); + ->defaultHeader('Accept', 'application/json'); + + $this->responses()->json(); $this->auth()->query('api_key', $apiKey); } @@ -40,6 +40,7 @@ final class UserResource extends Resource public function find(int $id): User { return $this + ->endpoint() ->get('/users/{id}', ['id' => $id]) ->entity(User::class, key: 'data'); } diff --git a/docs/08-http-client.md b/docs/08-http-client.md index 5ce270d..8510f63 100644 --- a/docs/08-http-client.md +++ b/docs/08-http-client.md @@ -36,12 +36,14 @@ final class ExampleApi extends Api ) { $this ->baseUrl('https://api.example.com') - ->defaultQueries(['api_key' => $apiKey]) + ->defaultQueries(['api_key' => $apiKey]); + + $this ->client($client) ->requestFactory($requestFactory) - ->streamFactory($streamFactory) - ->responses() - ->json(); + ->streamFactory($streamFactory); + + $this->responses()->json(); } } ``` diff --git a/docs/12-hooks.md b/docs/12-hooks.md index dabf5a2..f5fb888 100644 --- a/docs/12-hooks.md +++ b/docs/12-hooks.md @@ -13,10 +13,8 @@ final class ExampleApi extends Api { public function __construct(string $apiKey) { - $this - ->baseUrl('https://api.example.com') - ->responses() - ->json(); + $this->baseUrl('https://api.example.com'); + $this->responses()->json(); $this->hooks()->beforeRequest( fn (RequestContext $context) => $context From 891e5193ac284ffabb8920d1dd1e438df13e661e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 16:30:44 +0100 Subject: [PATCH 71/88] docs: improve API reference readability --- docs/03-api.md | 175 +++++++++++++++++++++++++++++++++---------- docs/05-resources.md | 50 +++++++++++-- docs/06-responses.md | 36 +++++++-- 3 files changed, 209 insertions(+), 52 deletions(-) diff --git a/docs/03-api.md b/docs/03-api.md index c3add2a..4528da6 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -4,7 +4,20 @@ This page documents the public API facade methods available to SDK authors and advanced SDK users. -## `send(string $method, string $path, array $pathParams = [], array $query = [], array $headers = [], string|StreamInterface|null $body = null): Response` +## Request Execution + +### `send()` + +```php +send( + string $method, + string $path, + array $pathParams = [], + array $query = [], + array $headers = [], + string|StreamInterface|null $body = null, +): Response +``` Public low-level request helper. @@ -34,7 +47,13 @@ Path parameters are encoded and replaced in `{name}` placeholders. - Authentication, plugins, cache, and hooks. - Response decoding and error mapping. -## `config(array $values = [], array $defaults = []): Config` +## SDK Setup + +### `config()` + +```php +config(array $values = [], array $defaults = []): Config +``` Public. @@ -56,7 +75,11 @@ $api->config(['timezone' => 'UTC']); $api->config()->get('timezone'); ``` -## `setup(): ApiSetup` +### `setup()` + +```php +setup(): ApiSetup +``` Public access to SDK setup and extension points without adding every setup method to the concrete SDK surface. @@ -68,7 +91,11 @@ $api->setup()->auth()->bearer($token); SDK authors can call the same setup methods directly from subclasses. -## `resource(string $class): Resource` +### `resource()` + +```php +resource(string $class): Resource +``` Protected helper for creating resource instances from an API class. @@ -82,7 +109,13 @@ final class ExampleApi extends Api } ``` -## `baseUrl(?string $baseUrl): static` +## Request Defaults + +### `baseUrl()` + +```php +baseUrl(?string $baseUrl): static +``` Protected fluent helper for configuring the API base URL. @@ -92,7 +125,11 @@ $this->baseUrl('https://api.example.com'); Full request URLs passed to resources override the configured base URL. -## `defaultQuery(string $name, mixed $value): static` +### `defaultQuery()` + +```php +defaultQuery(string $name, mixed $value): static +``` Protected fluent helper for configuring one query parameter applied to every request. @@ -100,7 +137,11 @@ Protected fluent helper for configuring one query parameter applied to every req $this->defaultQuery('api_key', $apiKey); ``` -## `defaultQueries(array $query): static` +### `defaultQueries()` + +```php +defaultQueries(array $query): static +``` Protected fluent helper for configuring query parameters applied to every request. @@ -111,10 +152,14 @@ $this->defaultQueries(['api_key' => $apiKey, 'locale' => 'en']); Query merge order is: ```text -API defaults < endpoint options < endpoint method query argument +API defaults < endpoint options ``` -## `defaultHeader(string $name, mixed $value): static` +### `defaultHeader()` + +```php +defaultHeader(string $name, mixed $value): static +``` Protected fluent helper for configuring one header applied to every request. @@ -122,7 +167,11 @@ Protected fluent helper for configuring one header applied to every request. $this->defaultHeader('Accept', 'application/json'); ``` -## `defaultHeaders(array $headers): static` +### `defaultHeaders()` + +```php +defaultHeaders(array $headers): static +``` Protected fluent helper for configuring headers applied to every request. @@ -132,50 +181,64 @@ $this->defaultHeaders(['Accept' => 'application/json']); Header names are not normalized by the package. -## `auth(): AuthBuilder` +## Pipeline Builders + +### `auth()` + +```php +auth(): AuthBuilder +``` Protected access to authentication configuration. ```php -$this->auth() - ->bearer($token) - ->query('appid', $apiKey); +$this->auth()->bearer($token); ``` 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. -## `hooks(): HookBuilder` +### `hooks()` + +```php +hooks(): HookBuilder +``` Protected access to request and response hooks. SDK users can access hooks through `setup()`. ```php $this->hooks()->beforeRequest($hook); $this->hooks()->afterResponse($hook); - -$api->setup()->hooks()->beforeRequest($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. -## `plugins(): PluginBuilder` +### `plugins()` + +```php +plugins(): PluginBuilder +``` Protected access to HTTPlug plugin configuration. SDK users can access plugins through `setup()`. ```php $this->plugins()->add($plugin, priority: 16); - -$api->setup()->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. -## `cache(CacheItemPoolInterface $pool): CacheBuilder` +### `cache()` + +```php +cache(CacheItemPoolInterface $pool): CacheBuilder +``` Protected access to PSR-6 HTTP response cache configuration. SDK users can access cache through `setup()`. @@ -184,20 +247,20 @@ $this ->cache($pool) ->defaultTtl(3600) ->methods(['GET', 'HEAD']); - -$api->setup()->cache($pool)->defaultTtl(3600); ``` See [Cache](09-cache.md) for cache options and plugin order. -## `client(ClientInterface $client): ClientBuilder` +### `client()` + +```php +client(ClientInterface $client): ClientBuilder +``` Protected access to PSR-18 client configuration. SDK users can access client configuration through `setup()`. ```php $this->client($client); - -$api->setup()->client($client); ``` SDK authors can configure PSR-17 factories on the returned builder: @@ -211,7 +274,11 @@ $this See [HTTP Client](08-http-client.md) for client and factory configuration. -## `logger(LoggerInterface $logger): LoggerBuilder` +### `logger()` + +```php +logger(LoggerInterface $logger): LoggerBuilder +``` Protected access to PSR-3 logger configuration. SDK users can access logging through `setup()`. @@ -219,13 +286,17 @@ Protected access to PSR-3 logger configuration. SDK users can access logging thr $this ->logger($logger) ->formatter($formatter); - -$api->setup()->logger($logger); ``` See [Logging](10-logging.md) for logger formatting and cache logging. -## `responses(): ResponseBuilder` +## Response Handling + +### `responses()` + +```php +responses(): ResponseBuilder +``` Protected access to response decoding configuration. @@ -244,7 +315,11 @@ Available response formats: When no format is configured, `raw()` is used. -## `errors(): ErrorBuilder` +### `errors()` + +```php +errors(): ErrorBuilder +``` Protected access to error handling configuration. @@ -288,11 +363,15 @@ $this->errors()->when(function (ErrorContext $context): ?Throwable { Status callbacks receive `ErrorContext` and must return a `Throwable`. Custom `when()` handlers receive `ErrorContext` and must return a `Throwable` when matched or `null` when not matched. -## `Config` +## Config Object `Config` stores SDK options. -### `all(): array` +### `all()` + +```php +all(): array +``` Returns all option values. @@ -300,7 +379,11 @@ Returns all option values. $options = $api->config()->all(); ``` -### `only(string ...$keys): array` +### `only()` + +```php +only(string ...$keys): array +``` Returns selected option values. Missing keys are omitted. @@ -308,7 +391,11 @@ Returns selected option values. Missing keys are omitted. $query = $api->config()->only('units', 'lang'); ``` -### `has(string $key): bool` +### `has()` + +```php +has(string $key): bool +``` Checks whether an option exists. A key with a `null` value still exists. @@ -316,7 +403,11 @@ Checks whether an option exists. A key with a `null` value still exists. $api->config()->has('timezone'); ``` -### `get(string $key, mixed $default = null): mixed` +### `get()` + +```php +get(string $key, mixed $default = null): mixed +``` Returns an option value or the default when the key does not exist. @@ -324,7 +415,11 @@ Returns an option value or the default when the key does not exist. $timezone = $api->config()->get('timezone', 'UTC'); ``` -### `set(string $key, mixed $value): self` +### `set()` + +```php +set(string $key, mixed $value): self +``` Sets one option value. @@ -332,7 +427,11 @@ Sets one option value. $api->config()->set('timezone', 'UTC'); ``` -### `merge(array $values): self` +### `merge()` + +```php +merge(array $values): self +``` Sets multiple option values. diff --git a/docs/05-resources.md b/docs/05-resources.md index cf3c825..68d3bdc 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -4,7 +4,13 @@ `Resource` keeps the SDK-user-facing domain surface small. SDK resource classes call `endpoint()` to start an endpoint request builder. -## `endpoint(): Endpoint` +## Endpoint Builder + +### `endpoint()` + +```php +endpoint(): Endpoint +``` Protected SDK-author helper. @@ -21,7 +27,11 @@ return $this Endpoint body helpers are immutable and return a cloned endpoint builder. -### `json(array $data): static` +### `json()` + +```php +json(array $data): static +``` Sets a JSON request body and `Content-Type: application/json`. @@ -33,7 +43,11 @@ return $this ->entity(User::class); ``` -### `form(array $data): static` +### `form()` + +```php +form(array $data): static +``` Sets a form-encoded request body and `Content-Type: application/x-www-form-urlencoded`. @@ -45,7 +59,11 @@ return $this ->entity(User::class); ``` -### `body(mixed $body): static` +### `body()` + +```php +body(mixed $body): static +``` Sets a raw string, stream, or null request body. @@ -61,7 +79,11 @@ Passing an array throws. Use `json()` or `form()` for array data. ## Endpoint Query And Headers -### `query(string $name, mixed $value): static` +### `query()` + +```php +query(string $name, mixed $value): static +``` Sets one endpoint-local query option. @@ -73,7 +95,11 @@ return $this ->collection(User::class, key: 'data'); ``` -### `queries(array $query): static` +### `queries()` + +```php +queries(array $query): static +``` Sets multiple endpoint-local query options. @@ -85,7 +111,11 @@ return $this ->collection(User::class, key: 'data'); ``` -### `header(string $name, mixed $value): static` +### `header()` + +```php +header(string $name, mixed $value): static +``` Sets one endpoint-local header. @@ -98,7 +128,11 @@ return $this ->raw(); ``` -### `headers(array $headers): static` +### `headers()` + +```php +headers(array $headers): static +``` Sets multiple endpoint-local headers. diff --git a/docs/06-responses.md b/docs/06-responses.md index 3d834af..53f102c 100644 --- a/docs/06-responses.md +++ b/docs/06-responses.md @@ -6,7 +6,11 @@ Response mapping covers decoded data, raw PSR responses, entities, collections, `Response` wraps decoded response data and the raw PSR response. -### `data(): mixed` +### `data()` + +```php +data(): mixed +``` Returns response data. @@ -22,7 +26,11 @@ When no response format is configured, this returns the raw response body string $data = $response->data(); ``` -### `raw(): ResponseInterface` +### `raw()` + +```php +raw(): ResponseInterface +``` Returns the raw PSR response. @@ -30,7 +38,11 @@ Returns the raw PSR response. $status = $response->raw()->getStatusCode(); ``` -### `entity(string $class, ?string $key = null): EntityInterface` +### `entity()` + +```php +entity(string $class, ?string $key = null): EntityInterface +``` Maps decoded response data to an entity class. @@ -43,7 +55,11 @@ return $this The class must implement `EntityInterface`. -### `collection(string $class, ?string $key = null): array` +### `collection()` + +```php +collection(string $class, ?string $key = null): array +``` Maps list data to a plain array of entities. @@ -54,7 +70,11 @@ return $this ->collection(User::class, key: 'data'); ``` -### `envelope(string $class): ResponseEnvelopeInterface` +### `envelope()` + +```php +envelope(string $class): ResponseEnvelopeInterface +``` Maps the response to a custom envelope. @@ -96,7 +116,11 @@ EntityInterface::fromArray(array $data, ?Context $context = null) ResponseEnvelopeInterface::fromResponse(Response $response, ?Context $context = null) ``` -### `config(): Config` +### `config()` + +```php +config(): Config +``` Returns the SDK config available while hydrating entities or response envelopes. From 5bbfc16c781276e319e9499b0d584389e618ef15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 16:44:16 +0100 Subject: [PATCH 72/88] docs: clarify resource response mapping --- docs/03-api.md | 10 +----- docs/04-resource-authoring.md | 65 ++++++++++++++++++----------------- 2 files changed, 34 insertions(+), 41 deletions(-) diff --git a/docs/03-api.md b/docs/03-api.md index 4528da6..597140e 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -99,15 +99,7 @@ resource(string $class): Resource Protected helper for creating resource instances from an API class. -```php -final class ExampleApi extends Api -{ - public function users(): UserResource - { - return $this->resource(UserResource::class); - } -} -``` +See [Resource Authoring](04-resource-authoring.md) for the recommended API-to-resource pattern. ## Request Defaults diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 9f31d48..8a016cd 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -212,6 +212,39 @@ return $this `collection()` returns a plain array of entities. +Use `envelope()` when the response carries metadata, pagination, or any API-specific envelope: + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->envelope(UserResponse::class); +``` + +Envelope classes must implement `ResponseEnvelopeInterface`: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; + +final class UserResponse implements ResponseEnvelopeInterface +{ + public function __construct( + private readonly User $user, + private readonly int $statusCode, + ) {} + + public static function fromResponse(Response $response, ?Context $context = null): static + { + return new self( + user: $response->entity(User::class, key: 'data'), + statusCode: $response->raw()->getStatusCode(), + ); + } +} +``` + ## Context `Context` carries SDK options into response mapping without passing the full `Api` instance around. @@ -298,38 +331,6 @@ final class UserResponse implements ResponseEnvelopeInterface Keep context usage focused on hydration decisions. Entities should still be data/value objects by default and should not perform hidden network calls. -Use `envelope()` when the response carries metadata, pagination, or any API-specific envelope: - -```php -return $this - ->get('/users/{id}', ['id' => $id]) - ->envelope(UserResponse::class); -``` - -Envelope classes must implement `ResponseEnvelopeInterface`: - -```php -use ProgrammatorDev\Api\Context\Context; -use ProgrammatorDev\Api\Response\Response; -use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; - -final class UserResponse implements ResponseEnvelopeInterface -{ - public function __construct( - private readonly User $user, - private readonly int $statusCode, - ) {} - - public static function fromResponse(Response $response, ?Context $context = null): static - { - return new self( - user: $response->entity(User::class, key: 'data'), - statusCode: $response->raw()->getStatusCode(), - ); - } -} -``` - ## 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. From d8092675e6c219c8892f7a61359e5b72f759603a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 16:55:30 +0100 Subject: [PATCH 73/88] refactor(response): rename envelope contract --- UPGRADE-3.0.md | 10 +++--- docs/01-getting-started.md | 12 +++---- docs/04-resource-authoring.md | 31 ++++++++++++------- docs/06-responses.md | 16 +++++----- docs/13-api-reference.md | 2 +- ...opeInterface.php => EnvelopeInterface.php} | 2 +- src/Response/Response.php | 16 +++++----- tests/Fixture/UserEnvelope.php | 4 +-- tests/Unit/ResponseTest.php | 2 +- 9 files changed, 52 insertions(+), 43 deletions(-) rename src/Contract/{ResponseEnvelopeInterface.php => EnvelopeInterface.php} (86%) diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md index faf2769..64176e2 100644 --- a/UPGRADE-3.0.md +++ b/UPGRADE-3.0.md @@ -41,12 +41,12 @@ Use response mapping helpers: ```php $response->entity(User::class); $response->collection(User::class, key: 'data'); -$response->envelope(UserResponse::class); +$response->envelope(UserEnvelope::class); ``` -Response envelopes must implement `ResponseEnvelopeInterface`. +Envelopes must implement `EnvelopeInterface`. -See [Responses: EntityInterface](docs/06-responses.md#entityinterface) and [Responses: ResponseEnvelopeInterface](docs/06-responses.md#responseenvelopeinterface) for details. +See [Responses: EntityInterface](docs/06-responses.md#entityinterface) and [Responses: EnvelopeInterface](docs/06-responses.md#envelopeinterface) for details. ## Config Replaces Ad Hoc Options @@ -58,7 +58,7 @@ $this->config($options, defaults: [ ]); ``` -The same config is available to entities, response envelopes, and hooks through context objects. +The same config is available to entities, envelopes, and hooks through context objects. See [API](docs/03-api.md) and [Responses: Context](docs/06-responses.md#context) for details. @@ -199,4 +199,4 @@ See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authorin ## Test Utilities Are Support Code -The v3 test helpers are intended to support this package and SDK author tests. Concrete SDKs should prefer focused tests around their own resources, entities, response envelopes, fake clients, and API-specific fluent helpers. +The v3 test helpers are intended to support this package and SDK author tests. Concrete SDKs should prefer focused tests around their own resources, entities, envelopes, fake clients, and API-specific fluent helpers. diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index cf0ee47..291e4e0 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -143,16 +143,16 @@ final class UserResource extends Resource $activeUsers = $api->users()->all(active: true); ``` -## Map Enveloped Responses +## Map Envelopes -If an API returns metadata, pagination, or any custom envelope, create a response envelope class. +If an API returns metadata, pagination, or any custom envelope, create an envelope class. ```php use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Response\Response; -use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; -final class UserResponse implements ResponseEnvelopeInterface +final class UserEnvelope implements EnvelopeInterface { public function __construct( /** @var User[] */ @@ -177,13 +177,13 @@ final class UserResponse implements ResponseEnvelopeInterface Then return it from the resource: ```php -public function all(int $page = 1): UserResponse +public function all(int $page = 1): UserEnvelope { return $this ->endpoint() ->query('page', $page) ->get('/users') - ->envelope(UserResponse::class); + ->envelope(UserEnvelope::class); } ``` diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 8a016cd..4cac20a 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -218,17 +218,17 @@ Use `envelope()` when the response carries metadata, pagination, or any API-spec return $this ->endpoint() ->get('/users/{id}', ['id' => $id]) - ->envelope(UserResponse::class); + ->envelope(UserEnvelope::class); ``` -Envelope classes must implement `ResponseEnvelopeInterface`: +Envelope classes must implement `EnvelopeInterface`: ```php use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Response\Response; -use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; -final class UserResponse implements ResponseEnvelopeInterface +final class UserEnvelope implements EnvelopeInterface { public function __construct( private readonly User $user, @@ -252,7 +252,7 @@ final class UserResponse implements ResponseEnvelopeInterface The flow is: ```text -SDK constructor options -> Api config -> Context -> EntityInterface or ResponseEnvelopeInterface +SDK constructor options -> Api config -> Context -> EntityInterface or EnvelopeInterface ``` Start by accepting SDK options and storing them in config: @@ -278,7 +278,7 @@ final class ExampleApi extends Api When a response is mapped, the API creates a context with that config. The same context is passed to: - `EntityInterface::fromArray(array $data, ?Context $context = null)` -- `ResponseEnvelopeInterface::fromResponse(Response $response, ?Context $context = null)` +- `EnvelopeInterface::fromResponse(Response $response, ?Context $context = null)` Entities can use config values during hydration: @@ -305,14 +305,14 @@ final class User implements EntityInterface } ``` -Response envelopes receive the same context: +Envelopes receive the same context: ```php use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Response\Response; -use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; -final class UserResponse implements ResponseEnvelopeInterface +final class UserEnvelope implements EnvelopeInterface { public function __construct( private readonly User $user, @@ -335,10 +335,10 @@ Keep context usage focused on hydration decisions. Entities should still be data 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. -For example, an SDK can expose a status filter without making the generic package know what a status filter is: +For example, an SDK can expose a reusable status filter without making the generic package know what a status filter is: ```php -final class UserResource extends Resource +trait HasStatusFilter { private ?string $status = null; @@ -349,6 +349,15 @@ final class UserResource extends Resource return $clone; } +} +``` + +Resources that support that API-specific filter can opt in to the trait: + +```php +final class UserResource extends Resource +{ + use HasStatusFilter; public function all(): array { diff --git a/docs/06-responses.md b/docs/06-responses.md index 53f102c..9730f7d 100644 --- a/docs/06-responses.md +++ b/docs/06-responses.md @@ -1,6 +1,6 @@ # Responses -Response mapping covers decoded data, raw PSR responses, entities, collections, custom response envelopes, and hydration context. +Response mapping covers decoded data, raw PSR responses, entities, collections, envelopes, and hydration context. ## `Response` @@ -73,7 +73,7 @@ return $this ### `envelope()` ```php -envelope(string $class): ResponseEnvelopeInterface +envelope(string $class): EnvelopeInterface ``` Maps the response to a custom envelope. @@ -82,10 +82,10 @@ Maps the response to a custom envelope. return $this ->endpoint() ->get('/users/{id}', ['id' => $id]) - ->envelope(UserResponse::class); + ->envelope(UserEnvelope::class); ``` -The class must implement `ResponseEnvelopeInterface`. +The class must implement `EnvelopeInterface`. ## `EntityInterface` @@ -97,9 +97,9 @@ public static function fromArray(array $data, ?Context $context = null): static; `fromArray()` is the mapping boundary for an entity. The package passes decoded response data to it; the SDK author decides how payload keys become constructor arguments, value objects, or derived values. -## `ResponseEnvelopeInterface` +## `EnvelopeInterface` -Response envelopes used by `Response::envelope()` must implement: +Envelopes used by `Response::envelope()` must implement: ```php public static function fromResponse(Response $response, ?Context $context = null): static; @@ -113,7 +113,7 @@ SDK users do not fetch context from `Response`. The package passes context into ```php EntityInterface::fromArray(array $data, ?Context $context = null) -ResponseEnvelopeInterface::fromResponse(Response $response, ?Context $context = null) +EnvelopeInterface::fromResponse(Response $response, ?Context $context = null) ``` ### `config()` @@ -122,7 +122,7 @@ ResponseEnvelopeInterface::fromResponse(Response $response, ?Context $context = config(): Config ``` -Returns the SDK config available while hydrating entities or response envelopes. +Returns the SDK config available while hydrating entities or envelopes. ```php $timezone = $context?->config()->get('timezone'); diff --git a/docs/13-api-reference.md b/docs/13-api-reference.md index fc17071..c96f7d4 100644 --- a/docs/13-api-reference.md +++ b/docs/13-api-reference.md @@ -4,7 +4,7 @@ This reference is split by where methods are available. - [API](03-api.md): `Api` setup methods and `Config`. - [Resources](05-resources.md): resource classes and endpoint request helpers. -- [Responses](06-responses.md): `Response`, `EntityInterface`, `ResponseEnvelopeInterface`, and `Context`. +- [Responses](06-responses.md): `Response`, `EntityInterface`, `EnvelopeInterface`, and `Context`. - [Authentication](07-authentication.md): `AuthBuilder` helpers and custom authentication. - [HTTP Client](08-http-client.md): `ClientBuilder` helpers and PSR-18/PSR-17 configuration. - [Cache](09-cache.md): `CacheBuilder` helpers and PSR-6 cache configuration. diff --git a/src/Contract/ResponseEnvelopeInterface.php b/src/Contract/EnvelopeInterface.php similarity index 86% rename from src/Contract/ResponseEnvelopeInterface.php rename to src/Contract/EnvelopeInterface.php index c7d3eab..87a7f8c 100644 --- a/src/Contract/ResponseEnvelopeInterface.php +++ b/src/Contract/EnvelopeInterface.php @@ -5,7 +5,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Response\Response; -interface ResponseEnvelopeInterface +interface EnvelopeInterface { public static function fromResponse(Response $response, ?Context $context = null): static; } diff --git a/src/Response/Response.php b/src/Response/Response.php index 57d67ae..7baabb5 100644 --- a/src/Response/Response.php +++ b/src/Response/Response.php @@ -4,7 +4,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; use Psr\Http\Message\ResponseInterface; class Response @@ -74,13 +74,13 @@ public function collection(string $class, ?string $key = null): array } /** - * @template T of ResponseEnvelopeInterface + * @template T of EnvelopeInterface * @param class-string $class * @return T */ - public function envelope(string $class): ResponseEnvelopeInterface + public function envelope(string $class): EnvelopeInterface { - $this->assertResponseEnvelopeClass($class); + $this->assertEnvelopeClass($class); return $class::fromResponse($this, $this->context); } @@ -118,13 +118,13 @@ private function assertEntityClass(string $class): void /** * @param class-string $class */ - private function assertResponseEnvelopeClass(string $class): void + private function assertEnvelopeClass(string $class): void { - if (!is_subclass_of($class, ResponseEnvelopeInterface::class)) { + if (!is_subclass_of($class, EnvelopeInterface::class)) { throw new \InvalidArgumentException(sprintf( - 'Response envelope class "%s" must implement %s.', + 'Envelope class "%s" must implement %s.', $class, - ResponseEnvelopeInterface::class + EnvelopeInterface::class )); } } diff --git a/tests/Fixture/UserEnvelope.php b/tests/Fixture/UserEnvelope.php index a8eabfe..d805722 100644 --- a/tests/Fixture/UserEnvelope.php +++ b/tests/Fixture/UserEnvelope.php @@ -4,9 +4,9 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Response\Response; -use ProgrammatorDev\Api\Contract\ResponseEnvelopeInterface; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; -class UserEnvelope implements ResponseEnvelopeInterface +class UserEnvelope implements EnvelopeInterface { public function __construct( private readonly User $user, diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 6751c2f..c7cf2cc 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -137,7 +137,7 @@ public function testCollectionRejectsClassThatDoesNotImplementEntity(): void $response->collection(\stdClass::class); } - public function testEnvelopeRejectsClassThatDoesNotImplementResponseEnvelope(): void + public function testEnvelopeRejectsClassThatDoesNotImplementEnvelope(): void { $response = new Response(['data' => ['id' => 1]], new PsrResponse()); From da7c03b44a7cadea8596dc79faf086b92ef47f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 17:09:17 +0100 Subject: [PATCH 74/88] docs: add retry plugin example --- docs/11-plugins.md | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/11-plugins.md b/docs/11-plugins.md index 97bcfb8..baeee38 100644 --- a/docs/11-plugins.md +++ b/docs/11-plugins.md @@ -22,25 +22,39 @@ $api->setup()->plugins()->add($retryPlugin, priority: 20); Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. +## Retry Plugin + +HTTPlug plugins can be configured directly. For example, the [retry plugin](https://docs.php-http.org/en/latest/plugins/retry.html) retries failed requests or retryable HTTP responses: + +```php +use Http\Client\Common\Plugin\RetryPlugin; + +$retryPlugin = new RetryPlugin([ + 'retries' => 2, +]); + +$this->plugins()->add($retryPlugin, priority: 25); +``` + +That priority runs retries after authentication is added to the request and before cache or logging. + +SDK users can apply the same plugin through `setup()`: + +```php +$api->setup()->plugins()->add($retryPlugin, priority: 25); +``` + ## Internal Plugin Order The package adds internal plugins with these priorities: -| Priority | Plugin | -| --- | --- | -| `50` | Content type | -| `40` | Content length | -| `30` | Authentication | -| `20` | Cache | -| `10` | Logger | - -## What Internal Plugins Do - -- [Content type](https://docs.php-http.org/en/latest/plugins/content-type.html): sets a `Content-Type` header when the request body makes it inferable and the header is not already set. -- [Content length](https://docs.php-http.org/en/latest/plugins/content-length.html): sets request body length metadata before the request is sent. -- [Authentication](https://docs.php-http.org/en/latest/plugins/authentication.html): applies credentials configured through `auth()`. -- [Cache](https://docs.php-http.org/en/latest/plugins/cache.html): reads and writes cacheable responses through PSR-6 cache support. Cache-specific logging is handled through cache listeners when logging is configured. -- [Logger](https://docs.php-http.org/en/latest/plugins/logger.html): logs HTTP requests and responses through the configured PSR-3 logger. +| Priority | Plugin | Description | +| --- | --- | --- | +| `50` | [Content type](https://docs.php-http.org/en/latest/plugins/content-type.html) | Sets a `Content-Type` header when the request body makes it inferable and the header is not already set. | +| `40` | [Content length](https://docs.php-http.org/en/latest/plugins/content-length.html) | Sets request body length metadata before the request is sent. | +| `30` | [Authentication](https://docs.php-http.org/en/latest/plugins/authentication.html) | Applies credentials configured through `auth()`. | +| `20` | [Cache](https://docs.php-http.org/en/latest/plugins/cache.html) | Reads and writes cacheable responses through cache configured with `cache()`. Cache-specific logging is handled through cache listeners when logging is configured. | +| `10` | [Logger](https://docs.php-http.org/en/latest/plugins/logger.html) | Logs HTTP requests and responses through the PSR-3 logger configured with `logger()`. | Custom plugins use the same priority system, so they can run before, between, or after internal plugins. From bbca155716817935be0b68f46d3b127ffa1c83ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 17:19:44 +0100 Subject: [PATCH 75/88] docs: improve after response hook example --- docs/12-hooks.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/12-hooks.md b/docs/12-hooks.md index f5fb888..2117718 100644 --- a/docs/12-hooks.md +++ b/docs/12-hooks.md @@ -23,7 +23,9 @@ final class ExampleApi extends Api ); $this->hooks()->afterResponse( - fn (ResponseContext $context) => $context->response() + fn (ResponseContext $context) => $context + ->response() + ->withoutHeader('X-Debug-Trace') ); } } @@ -47,7 +49,7 @@ Return a `RequestInterface` to replace the request. Return `null` to leave it un ```php $this->hooks()->afterResponse(function (ResponseContext $context) { - return $context->response(); + return $context->response()->withoutHeader('X-Debug-Trace'); }); ``` From 4f5fe201381cb30cda2bcc2fcf3bfa3071d48eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 19 Jun 2026 17:29:10 +0100 Subject: [PATCH 76/88] docs: simplify upgrade guidance --- README.md | 3 +-- UPGRADE-3.0.md | 21 ++++++++------------- docs/00-index.md | 3 +-- docs/12-hooks.md | 3 +-- docs/13-api-reference.md | 17 ----------------- 5 files changed, 11 insertions(+), 36 deletions(-) delete mode 100644 docs/13-api-reference.md diff --git a/README.md b/README.md index e86d2f5..881a76e 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,7 @@ SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementat - [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. -- [API Reference](docs/13-api-reference.md): authoring methods and contracts. ## Upgrading -Version 3.0 is a full architecture refresh. See [Upgrade to 3.0](UPGRADE-3.0.md) for the high-level changes. +See [Upgrade to 3.0](UPGRADE-3.0.md) for the high-level changes. diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md index 64176e2..fe5b346 100644 --- a/UPGRADE-3.0.md +++ b/UPGRADE-3.0.md @@ -76,14 +76,13 @@ HTTP errors do not throw by default. Configure error handling explicitly: ```php $this->errors()->status(404, NotFoundException::class); -$this->errors()->when(fn (ErrorContext $context) => null); ``` See [API](docs/03-api.md) and [Responses](docs/06-responses.md) for details. ## Infrastructure Uses Builders -PSR-18 clients, PSR-17 factories, PSR-6 cache, PSR-3 logging, HTTPlug authentication, plugins, and hooks are still supported. In v3, they are configured through grouped builders instead of scattered low-level methods. +PSR-18 clients, PSR-17 factories, PSR-6 cache, PSR-3 logging, HTTPlug authentication, plugins, and hooks are still supported. They are configured through grouped builders instead of scattered low-level methods. ```php $this->auth()->bearer($token); @@ -96,18 +95,14 @@ $this->client($client)->requestFactory($requestFactory); `plugins()` remains the right place for transport-level behavior. `hooks()` remains available for request and response lifecycle customization, but response decoding and error handling now have dedicated builders. -Authentication is replaced by each `auth()` call unless you explicitly use `chain()`: +Most SDKs only need one authentication helper: ```php -use Http\Message\Authentication\Bearer; -use Http\Message\Authentication\QueryParam; - -$this->auth()->chain( - new Bearer($token), - new QueryParam(['api_key' => $apiKey]), -); +$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. ## Defaults And Endpoint Overrides @@ -116,7 +111,7 @@ SDK authors can still configure request defaults: ```php $this->defaultHeaders(['Accept' => 'application/json']); -$this->defaultQueries($this->config()->only(['units', 'locale'])); +$this->defaultQueries($this->config()->only('units', 'locale')); ``` SDK authors can configure endpoint-specific cache defaults inside the endpoint chain: @@ -169,7 +164,7 @@ See [API](docs/03-api.md) and [Design Approach: Escape Hatch](docs/02-design-app `send()` is public as an advanced escape hatch. SDK users can call endpoints that are not modeled by the concrete SDK while still using the SDK's configured base URL, authentication, cache, plugins, hooks, decoding, and error handling. ```php -$response = $api->send('GET', '/unmodeled-endpoint', queries: [ +$response = $api->send('GET', '/unmodeled-endpoint', query: [ 'page' => 1, ]); ``` @@ -199,4 +194,4 @@ See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authorin ## Test Utilities Are Support Code -The v3 test helpers are intended to support this package and SDK author tests. Concrete SDKs should prefer focused tests around their own resources, entities, envelopes, fake clients, and API-specific fluent helpers. +The test helpers are intended to support this package and SDK author tests. Concrete SDKs should prefer focused tests around their own resources, entities, envelopes, fake clients, and API-specific fluent helpers. diff --git a/docs/00-index.md b/docs/00-index.md index 822a939..33374c7 100644 --- a/docs/00-index.md +++ b/docs/00-index.md @@ -41,11 +41,10 @@ SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementat - [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. -- [API Reference](13-api-reference.md): authoring methods and contracts. ## Upgrading -Version 3.0 is a full architecture refresh. See [Upgrade to 3.0](../UPGRADE-3.0.md) for the high-level changes. +See [Upgrade to 3.0](../UPGRADE-3.0.md) for the high-level changes. ## Navigation diff --git a/docs/12-hooks.md b/docs/12-hooks.md index 2117718..b67eed8 100644 --- a/docs/12-hooks.md +++ b/docs/12-hooks.md @@ -84,7 +84,7 @@ The shared `Context` gives hooks access to SDK config without injecting the full ## Order -The current v3 request flow is: +The current request flow is: ```text create request @@ -100,4 +100,3 @@ return Response ## Navigation - Previous: [Plugins](11-plugins.md) -- Next: [API Reference](13-api-reference.md) diff --git a/docs/13-api-reference.md b/docs/13-api-reference.md deleted file mode 100644 index c96f7d4..0000000 --- a/docs/13-api-reference.md +++ /dev/null @@ -1,17 +0,0 @@ -# API Reference - -This reference is split by where methods are available. - -- [API](03-api.md): `Api` setup methods and `Config`. -- [Resources](05-resources.md): resource classes and endpoint request helpers. -- [Responses](06-responses.md): `Response`, `EntityInterface`, `EnvelopeInterface`, and `Context`. -- [Authentication](07-authentication.md): `AuthBuilder` helpers and custom authentication. -- [HTTP Client](08-http-client.md): `ClientBuilder` helpers and PSR-18/PSR-17 configuration. -- [Cache](09-cache.md): `CacheBuilder` helpers and PSR-6 cache configuration. -- [Logging](10-logging.md): `LoggerBuilder` helpers and PSR-3 logging configuration. -- [Plugins](11-plugins.md): `PluginBuilder` helpers and internal plugin order. -- [Hooks](12-hooks.md): `HookBuilder`, request hooks, response hooks, and hook contexts. - -## Navigation - -- Previous: [Hooks](12-hooks.md) From 60fcbfbd2cd25e96db33e3baac6854e92f391913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 15:28:39 +0100 Subject: [PATCH 77/88] docs: improve getting started flow --- docs/01-getting-started.md | 82 ++++++++++++-------------------------- 1 file changed, 25 insertions(+), 57 deletions(-) diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 291e4e0..37f9a13 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -1,6 +1,6 @@ # Getting Started -These examples show the current SDK authoring API. +This guide builds a small SDK with one entity, one resource, and one API facade. ## Install @@ -10,40 +10,6 @@ composer require programmatordev/php-api-sdk The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically. SDK packages may still require or suggest concrete implementations when they want tighter control over the default HTTP stack. -## Create An API Class - -The API class is the SDK facade. It configures shared options and exposes purpose-built resources. - -```php -use ProgrammatorDev\Api\Api; - -final class ExampleApi extends Api -{ - public function __construct(string $apiKey) - { - parent::__construct(); - - $this - ->baseUrl('https://api.example.com') - ->defaultQueries(['locale' => 'en']) - ->defaultHeaders(['Accept' => 'application/json']); - - $this->auth()->query('api_key', $apiKey); - } - - public function users(): UserResource - { - return $this->resource(UserResource::class); - } -} -``` - -The final SDK user works with `users()`, not raw request execution: - -```php -$user = $api->users()->find(1); -``` - ## Create An Entity Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `EntityInterface`. @@ -73,7 +39,7 @@ final class User implements EntityInterface ## Create A Resource -Resources group endpoint methods. Use `endpoint()` to start an endpoint request builder, then map the response. +Resources group endpoint methods. Use `endpoint()` to start a request, execute it, and map the response. ```php use ProgrammatorDev\Api\Resource; @@ -110,37 +76,39 @@ final class UserResource extends Resource } ``` -Path parameters are passed as the second argument to the HTTP helper: +See [Resource Authoring](04-resource-authoring.md) for query parameters, headers, request bodies, cache overrides, and API-specific fluent chains. -```php -$this->endpoint()->get('/users/{id}', ['id' => $id]); -``` +## Create An API Class -Endpoint-specific query parameters are configured on the endpoint builder: +The API class is the SDK facade. It configures shared options and exposes purpose-built resources. ```php -$this - ->endpoint() - ->query('locale', 'pt') - ->get('/users/{id}', ['id' => $id]); -``` - -SDK authors decide how SDK users customize requests. Often a method argument is enough: +use ProgrammatorDev\Api\Api; -```php -final class UserResource extends Resource +final class ExampleApi extends Api { - public function all(bool $active = true): array + public function __construct(string $apiKey) { - return $this - ->endpoint() - ->query('active', $active) - ->get('/users') - ->collection(User::class, key: 'data'); + parent::__construct(); + + $this->baseUrl('https://api.example.com'); + $this->auth()->query('api_key', $apiKey); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); } } +``` + +SDK users work with resources and endpoint methods, not raw request execution: -$activeUsers = $api->users()->all(active: true); +```php +$api = new ExampleApi('secret'); + +$user = $api->users()->find(1); +$users = $api->users()->all(); ``` ## Map Envelopes From e7b217c192ea84272f8f2ac26505968205f6bb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 16:18:46 +0100 Subject: [PATCH 78/88] docs: refine authoring examples --- docs/02-design-approach.md | 6 +----- docs/04-resource-authoring.md | 5 +---- docs/05-resources.md | 14 ++++++++++++++ docs/08-http-client.md | 22 ++++++++-------------- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/02-design-approach.md b/docs/02-design-approach.md index 23431ce..9a0441e 100644 --- a/docs/02-design-approach.md +++ b/docs/02-design-approach.md @@ -16,12 +16,8 @@ final class ExampleApi extends Api { public function __construct(string $apiKey) { - $this - ->baseUrl('https://api.example.com') - ->defaultHeader('Accept', 'application/json'); - + $this->baseUrl('https://api.example.com'); $this->responses()->json(); - $this->auth()->query('api_key', $apiKey); } diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 4cac20a..a75ee9f 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -264,10 +264,7 @@ final class ExampleApi extends Api { parent::__construct(); - $this - ->baseUrl('https://api.example.com') - ->defaultQueries(['api_key' => $apiKey]); - + $this->baseUrl('https://api.example.com'); $this->config($options, defaults: [ 'timezone' => 'UTC', ]); diff --git a/docs/05-resources.md b/docs/05-resources.md index 68d3bdc..238b19d 100644 --- a/docs/05-resources.md +++ b/docs/05-resources.md @@ -172,6 +172,20 @@ array $pathParams = [] Use `query()`, `queries()`, `header()`, and `headers()` to configure request-local query parameters and headers before calling the HTTP helper. +## Endpoint Cache Defaults + +SDK authors can configure endpoint-specific cache defaults on the endpoint builder: + +```php +return $this + ->endpoint() + ->cache(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. + ## Resource Cache Overrides `withCache()` lets SDK users override cache behavior for one resource chain while keeping query, headers, body, and verbs inside `Endpoint`. diff --git a/docs/08-http-client.md b/docs/08-http-client.md index 8510f63..ae632ec 100644 --- a/docs/08-http-client.md +++ b/docs/08-http-client.md @@ -21,27 +21,21 @@ $this SDK authors can configure the client and factories inside the API constructor when discovery should not choose them automatically. ```php +use Nyholm\Psr7\Factory\Psr17Factory; use ProgrammatorDev\Api\Api; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; -use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\HttpClient\Psr18Client; final class ExampleApi extends Api { - public function __construct( - ClientInterface $client, - RequestFactoryInterface $requestFactory, - StreamFactoryInterface $streamFactory, - string $apiKey, - ) { - $this - ->baseUrl('https://api.example.com') - ->defaultQueries(['api_key' => $apiKey]); + public function __construct() + { + $client = new Psr18Client(); + $psr17Factory = new Psr17Factory(); $this ->client($client) - ->requestFactory($requestFactory) - ->streamFactory($streamFactory); + ->requestFactory($psr17Factory) + ->streamFactory($psr17Factory); $this->responses()->json(); } From 16aa557fbb11cc3f9a680adbfaff96173a22a1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 17:16:37 +0100 Subject: [PATCH 79/88] refactor(runtime): route resources through runtime --- docs/04-resource-authoring.md | 4 +- src/Api.php | 38 ++++++++-------- src/Endpoint.php | 8 ++-- src/Resource.php | 4 +- src/Runtime.php | 69 ++++++++++++++++++++++++++++++ tests/Fixture/UserResource.php | 2 +- tests/Integration/ResourceTest.php | 13 ++++++ 7 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 src/Runtime.php diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index a75ee9f..09d012e 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -39,6 +39,8 @@ final class ExampleApi extends Api `Api::resource()` creates a fresh resource instance. Resource-chain infrastructure overrides, such as `withCache()`, are immutable, so fluent customizations do not leak into later calls. +Resources receive the SDK `Runtime` internally. The runtime carries config and executes requests through the configured SDK pipeline, so resources do not need the full `Api` facade. + ## Endpoint Requests Use `endpoint()` inside resource methods to create the request builder: @@ -272,7 +274,7 @@ final class ExampleApi extends Api } ``` -When a response is mapped, the API creates a context with that config. The same context is passed to: +When a response is mapped, the runtime creates a context with that config. The same context is passed to: - `EntityInterface::fromArray(array $data, ?Context $context = null)` - `EnvelopeInterface::fromResponse(Response $response, ?Context $context = null)` diff --git a/src/Api.php b/src/Api.php index f13e719..8bd6ac1 100644 --- a/src/Api.php +++ b/src/Api.php @@ -11,8 +11,6 @@ use ProgrammatorDev\Api\Builder\PluginBuilder; use ProgrammatorDev\Api\Builder\ResponseBuilder; 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; @@ -73,8 +71,7 @@ public function send( array $pathParams = [], array $query = [], array $headers = [], - string|StreamInterface|null $body = null, - ?PipelineOptions $pipelineOptions = null + string|StreamInterface|null $body = null ): Response { $options = (new RequestOptions()) @@ -82,26 +79,13 @@ public function send( ->withHeaders($headers) ->withBody($body); - $context = new Context($this->config); - - $response = $this->transport()->send( + return $this->runtime()->send( method: $method, path: $path, pathParams: $pathParams, - options: $options, - pipelineOptions: $pipelineOptions, - context: $context - ); - - $apiResponse = new Response( - data: $this->responseDecoder()->decode($response), - rawResponse: $response, - context: $context + requestOptions: $options, + pipelineOptions: new PipelineOptions() ); - - $this->errorBuilder->throwIfMatched(new ErrorContext($apiResponse, $context)); - - return $apiResponse; } public function setup(): ApiSetup @@ -118,7 +102,7 @@ public function setup(): ApiSetup */ protected function resource(string $class): Resource { - return new $class($this); + return new $class($this->runtime()); } protected function baseUrl(?string $baseUrl): static @@ -234,4 +218,16 @@ private function responseDecoder(): ResponseDecoder { return new ResponseDecoder($this->responseBuilder); } + + private function runtime(): Runtime + { + return new Runtime( + config: $this->config, + // Build transport at send time so resources created before later + // setup() changes still use the latest API configuration. + transport: fn(): Transport => $this->transport(), + responseDecoder: $this->responseDecoder(), + errorBuilder: $this->errorBuilder + ); + } } diff --git a/src/Endpoint.php b/src/Endpoint.php index d264a05..55a73a6 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -14,7 +14,7 @@ class Endpoint private RequestOptions $options; public function __construct( - private readonly Api $api, + private readonly Runtime $runtime, private PipelineOptions $pipelineOptions ) { $this->options = new RequestOptions(); @@ -160,13 +160,11 @@ public function trace(string $path, array $pathParams = []): Response */ private function send(string $method, string $path, array $pathParams = []): Response { - return $this->api->send( + return $this->runtime->send( method: $method, path: $path, pathParams: $pathParams, - query: $this->options->getQuery(), - headers: $this->options->getHeaders(), - body: $this->options->getBody(), + requestOptions: $this->options, pipelineOptions: $this->pipelineOptions ); } diff --git a/src/Resource.php b/src/Resource.php index c834f01..14d6d48 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -11,7 +11,7 @@ abstract class Resource private PipelineOptions $pipelineOptions; public function __construct( - protected readonly Api $api + protected readonly Runtime $runtime ) { $this->pipelineOptions = new PipelineOptions(); } @@ -28,7 +28,7 @@ public function withCache(callable $configure): static protected function endpoint(): Endpoint { - return new Endpoint($this->api, $this->pipelineOptions); + return new Endpoint($this->runtime, $this->pipelineOptions); } private function withPipelineOptions(PipelineOptions $pipelineOptions): static diff --git a/src/Runtime.php b/src/Runtime.php new file mode 100644 index 0000000..312ea75 --- /dev/null +++ b/src/Runtime.php @@ -0,0 +1,69 @@ +config; + } + + /** + * @throws ClientExceptionInterface + * @throws \JsonException + * @throws \RuntimeException + * @throws \Throwable + */ + public function send( + string $method, + string $path, + array $pathParams, + RequestOptions $requestOptions, + PipelineOptions $pipelineOptions + ): Response { + $context = new Context($this->config); + + $rawResponse = ($this->transport)()->send( + method: $method, + path: $path, + pathParams: $pathParams, + options: $requestOptions, + pipelineOptions: $pipelineOptions, + context: $context + ); + + $response = new Response( + data: $this->responseDecoder->decode($rawResponse), + rawResponse: $rawResponse, + context: $context + ); + + $this->errorBuilder->throwIfMatched(new ErrorContext($response, $context)); + + return $response; + } +} diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php index de4b8fb..c415488 100644 --- a/tests/Fixture/UserResource.php +++ b/tests/Fixture/UserResource.php @@ -128,7 +128,7 @@ public function findWithConfiguredTimezone(int|string $id): User { return $this ->endpoint() - ->query('timezone', $this->api->config()->get('timezone')) + ->query('timezone', $this->runtime->config()->get('timezone')) ->get('/users/{id}', ['id' => $id]) ->entity(User::class); } diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index 8e86127..f964fb5 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -196,6 +196,19 @@ public function testResourceCanReadSdkConfig(): void $this->assertSame('https://api.example.com/users/1?locale=en&timezone=UTC', (string) $this->client->getLastRequest()->getUri()); } + public function testResourceUsesLatestApiSetupWhenRequestIsSent(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $users = $this->api->users(); + + $this->api->setup()->defaultQuery('units', 'metric'); + + $users->find(1); + + $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $this->client->getLastRequest()->getUri()); + } + public function testNullQueryValuesAreOmitted(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); From 8bfcfd08af6032a0b201b0bcf0b4f576c8041584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 17:21:21 +0100 Subject: [PATCH 80/88] refactor(setup): rename api setup surface --- docs/03-api.md | 2 +- src/Api.php | 6 ++++-- src/{ApiSetup.php => Setup.php} | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) rename src/{ApiSetup.php => Setup.php} (96%) diff --git a/docs/03-api.md b/docs/03-api.md index 597140e..e97487e 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -78,7 +78,7 @@ $api->config()->get('timezone'); ### `setup()` ```php -setup(): ApiSetup +setup(): Setup ``` Public access to SDK setup and extension points without adding every setup method to the concrete SDK surface. diff --git a/src/Api.php b/src/Api.php index 8bd6ac1..4ffff24 100644 --- a/src/Api.php +++ b/src/Api.php @@ -88,9 +88,11 @@ public function send( ); } - public function setup(): ApiSetup + public function setup(): Setup { - return new ApiSetup( + return new Setup( + // Keep setup helpers protected on Api while exposing them through + // one explicit SDK-user setup surface. fn(string $method, array $arguments): mixed => $this->{$method}(...$arguments) ); } diff --git a/src/ApiSetup.php b/src/Setup.php similarity index 96% rename from src/ApiSetup.php rename to src/Setup.php index 970fe4f..63d7a05 100644 --- a/src/ApiSetup.php +++ b/src/Setup.php @@ -15,10 +15,10 @@ use Psr\Http\Client\ClientInterface; use Psr\Log\LoggerInterface; -class ApiSetup +class Setup { /** - * @param \Closure(string, array): mixed $call + * @param \Closure(string, array): mixed $call Calls protected Api setup helpers. */ public function __construct( private readonly \Closure $call From 88aea4fece57c388eb9dffb65b9f1b231933ebfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 17:33:11 +0100 Subject: [PATCH 81/88] test(runtime): cover late setup changes --- docs/04-resource-authoring.md | 4 +--- tests/Integration/ResourceTest.php | 2 +- tests/Integration/ResponseDecodingTest.php | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md index 09d012e..c979902 100644 --- a/docs/04-resource-authoring.md +++ b/docs/04-resource-authoring.md @@ -39,8 +39,6 @@ final class ExampleApi extends Api `Api::resource()` creates a fresh resource instance. Resource-chain infrastructure overrides, such as `withCache()`, are immutable, so fluent customizations do not leak into later calls. -Resources receive the SDK `Runtime` internally. The runtime carries config and executes requests through the configured SDK pipeline, so resources do not need the full `Api` facade. - ## Endpoint Requests Use `endpoint()` inside resource methods to create the request builder: @@ -274,7 +272,7 @@ final class ExampleApi extends Api } ``` -When a response is mapped, the runtime creates a context with that config. The same context is passed to: +When a response is mapped, the SDK creates a context with that config. The same context is passed to: - `EntityInterface::fromArray(array $data, ?Context $context = null)` - `EnvelopeInterface::fromResponse(Response $response, ?Context $context = null)` diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php index f964fb5..1764368 100644 --- a/tests/Integration/ResourceTest.php +++ b/tests/Integration/ResourceTest.php @@ -196,7 +196,7 @@ public function testResourceCanReadSdkConfig(): void $this->assertSame('https://api.example.com/users/1?locale=en&timezone=UTC', (string) $this->client->getLastRequest()->getUri()); } - public function testResourceUsesLatestApiSetupWhenRequestIsSent(): void + public function testResourceCreatedBeforeSetupChangeUsesLatestRequestDefaults(): void { $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); diff --git a/tests/Integration/ResponseDecodingTest.php b/tests/Integration/ResponseDecodingTest.php index d8f0ca3..a1c6767 100644 --- a/tests/Integration/ResponseDecodingTest.php +++ b/tests/Integration/ResponseDecodingTest.php @@ -94,4 +94,23 @@ public function testCustomDecoderRunsThroughApiResourcePipeline(): void 'body' => 'accepted', ], $response->data()); } + + public function testResourceCreatedBeforeSetupChangeUsesLatestResponseDecoder(): void + { + $client = $this->mockClient(new Response(status: 202, body: 'accepted')); + $api = new PlainApi($client); + $resource = $api->raw(); + + $api->decodeWith(fn (ResponseInterface $response): array => [ + 'status' => $response->getStatusCode(), + 'body' => (string) $response->getBody(), + ]); + + $response = $resource->fetch(); + + $this->assertSame([ + 'status' => 202, + 'body' => 'accepted', + ], $response->data()); + } } From 65d282fb19917f67503e502bdb71dccf13c31027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 17:36:43 +0100 Subject: [PATCH 82/88] docs(builders): clarify pipeline behavior --- src/Builder/AuthBuilder.php | 2 ++ src/Builder/HookBuilder.php | 6 ++++++ src/Builder/PluginBuilder.php | 2 ++ src/Request/RequestOptions.php | 2 ++ 4 files changed, 12 insertions(+) diff --git a/src/Builder/AuthBuilder.php b/src/Builder/AuthBuilder.php index af133a3..f87203b 100644 --- a/src/Builder/AuthBuilder.php +++ b/src/Builder/AuthBuilder.php @@ -63,6 +63,8 @@ public function custom(callable $callback): self public function use(Authentication $authentication): self { + // Authentication is intentionally replaced, not appended. Use chain() + // when multiple authentication strategies must run on the same request. $this->authentication = $authentication; return $this; diff --git a/src/Builder/HookBuilder.php b/src/Builder/HookBuilder.php index 6a13426..5bf94d2 100644 --- a/src/Builder/HookBuilder.php +++ b/src/Builder/HookBuilder.php @@ -46,6 +46,8 @@ public function applyBeforeRequestHooks(RequestContext $context): RequestInterfa $request = $context->request(); foreach ($this->sort($this->beforeRequestHooks) as $hook) { + // Each hook receives the request produced by the previous hook so + // small request mutations can compose without sharing mutable state. $replacement = $hook(new RequestContext($request, $context->apiContext())); if ($replacement instanceof RequestInterface) { @@ -70,6 +72,8 @@ public function applyAfterResponseHooks(ResponseContext $context): ResponseInter $response = $context->response(); foreach ($this->sort($this->afterResponseHooks) as $hook) { + // Later hooks see the response returned by earlier hooks, mirroring + // the before-request pipeline while keeping PSR responses immutable. $replacement = $hook(new ResponseContext($context->request(), $response, $context->apiContext())); if ($replacement instanceof ResponseInterface) { @@ -98,6 +102,8 @@ private function sort(array $hooks): array krsort($hooks); + // Higher priority runs first; hooks with the same priority keep their + // registration order because the per-priority lists are never re-sorted. return array_values(array_merge(...array_values($hooks))); } } diff --git a/src/Builder/PluginBuilder.php b/src/Builder/PluginBuilder.php index 27946f9..b973ece 100644 --- a/src/Builder/PluginBuilder.php +++ b/src/Builder/PluginBuilder.php @@ -48,6 +48,8 @@ public function getPlugins(): array $plugins = $this->plugins; krsort($plugins); + // Higher priority plugins are placed earlier in the HTTPlug chain; equal + // priority preserves registration order inside each bucket. return array_values(array_merge(...array_values($plugins))); } } diff --git a/src/Request/RequestOptions.php b/src/Request/RequestOptions.php index ee1e1fa..12bbccb 100644 --- a/src/Request/RequestOptions.php +++ b/src/Request/RequestOptions.php @@ -66,6 +66,8 @@ public function withBody(string|StreamInterface|null $body): self private function filterNullValues(array $values): array { + // Null means "omit this request-local query value"; false, 0, and empty + // strings are still meaningful values and must be preserved. return array_filter($values, static fn(mixed $value): bool => $value !== null); } } From 6f35162236fa600d2484bcd3e737c0d1020b76d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 17:40:20 +0100 Subject: [PATCH 83/88] refactor(api): simplify client builder initialization --- src/Api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api.php b/src/Api.php index 4ffff24..aea3556 100644 --- a/src/Api.php +++ b/src/Api.php @@ -51,7 +51,7 @@ abstract class Api public function __construct() { $this->config = new Config(); - $this->clientBuilder ??= new ClientBuilder(); + $this->clientBuilder = new ClientBuilder(); $this->authBuilder = new AuthBuilder(); $this->pluginBuilder = new PluginBuilder(); $this->responseBuilder = new ResponseBuilder(); From 90df2d8b2bef779dd560f59541505d733c707117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 17:55:24 +0100 Subject: [PATCH 84/88] ci: update php test workflow --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03e950..b578faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -on: +on: push: branches: - main @@ -10,17 +10,22 @@ on: - main - "*.x" +permissions: + contents: read + jobs: tests: name: PHP ${{ matrix.php }} Test runs-on: ubuntu-latest + strategy: + fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -29,6 +34,21 @@ jobs: tools: composer:v2 coverage: none + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-composer- + + - name: Validate Composer configuration + run: composer validate --strict + - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress From 9e3518033a553d23bec0f0b7df41c6f5bd2c805a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 21:28:59 +0100 Subject: [PATCH 85/88] refactor(api): simplify runtime wiring --- src/Api.php | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/Api.php b/src/Api.php index aea3556..5e43ca9 100644 --- a/src/Api.php +++ b/src/Api.php @@ -201,9 +201,11 @@ public function config(array $values = [], array $defaults = []): Config return $this->config; } - private function transport(): Transport + private function runtime(): Runtime { - return new Transport( + // Build transport at send time so resources created before later + // setup() changes still use the latest API configuration. + $transport = fn(): Transport => new Transport( clientBuilder: $this->clientBuilder, authBuilder: $this->authBuilder, pluginBuilder: $this->pluginBuilder, @@ -214,21 +216,13 @@ private function transport(): Transport defaultQueries: $this->defaultQueries, defaultHeaders: $this->defaultHeaders ); - } - private function responseDecoder(): ResponseDecoder - { - return new ResponseDecoder($this->responseBuilder); - } + $responseDecoder = new ResponseDecoder($this->responseBuilder); - private function runtime(): Runtime - { return new Runtime( config: $this->config, - // Build transport at send time so resources created before later - // setup() changes still use the latest API configuration. - transport: fn(): Transport => $this->transport(), - responseDecoder: $this->responseDecoder(), + transport: $transport, + responseDecoder: $responseDecoder, errorBuilder: $this->errorBuilder ); } From 0c97fb279c9e375b1712a801aa5c41d6fad9339a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 20 Jun 2026 23:35:25 +0100 Subject: [PATCH 86/88] feat(cache): use one hour default ttl --- docs/09-cache.md | 2 ++ src/Builder/CacheBuilder.php | 2 +- tests/Unit/Builder/CacheBuilderTest.php | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/09-cache.md b/docs/09-cache.md index 1297c61..51c2812 100644 --- a/docs/09-cache.md +++ b/docs/09-cache.md @@ -19,6 +19,8 @@ use Symfony\Component\Cache\Adapter\FilesystemAdapter; $api->setup()->cache(new FilesystemAdapter())->defaultTtl(3600); ``` +When no TTL is configured, cached responses use a default TTL of 3600 seconds. + ## Options ```php diff --git a/src/Builder/CacheBuilder.php b/src/Builder/CacheBuilder.php index ecd4d3e..7e2fc2d 100644 --- a/src/Builder/CacheBuilder.php +++ b/src/Builder/CacheBuilder.php @@ -9,7 +9,7 @@ class CacheBuilder { public function __construct( private CacheItemPoolInterface $pool, - private ?int $defaultTtl = 60, + private ?int $defaultTtl = 3600, private array $methods = [Method::GET, Method::HEAD], private array $responseCacheDirectives = ['max-age'] ) {} diff --git a/tests/Unit/Builder/CacheBuilderTest.php b/tests/Unit/Builder/CacheBuilderTest.php index 80136ea..24019ec 100644 --- a/tests/Unit/Builder/CacheBuilderTest.php +++ b/tests/Unit/Builder/CacheBuilderTest.php @@ -15,7 +15,7 @@ public function testCacheBuilderUsesDefaults(): void $cacheBuilder = new CacheBuilder($pool); $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame(60, $cacheBuilder->getDefaultTtl()); + $this->assertSame(3600, $cacheBuilder->getDefaultTtl()); $this->assertSame(['GET', 'HEAD'], $cacheBuilder->getMethods()); $this->assertSame(['max-age'], $cacheBuilder->getResponseCacheDirectives()); } From ccf23e2f5195b658c52a384022429c55305fe38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 21 Jun 2026 16:13:56 +0100 Subject: [PATCH 87/88] refactor(client): simplify plugin defaults --- src/Builder/ClientBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Builder/ClientBuilder.php b/src/Builder/ClientBuilder.php index 87e392e..bc5bd6e 100644 --- a/src/Builder/ClientBuilder.php +++ b/src/Builder/ClientBuilder.php @@ -23,12 +23,12 @@ public function __construct( } /** - * @param list<\Http\Client\Common\Plugin>|null $plugins + * @param list<\Http\Client\Common\Plugin> $plugins */ - public function getClient(?array $plugins = null): HttpMethodsClient + public function getClient(array $plugins = []): HttpMethodsClient { $pluginClientFactory = new PluginClientFactory(); - $client = $pluginClientFactory->createClient($this->client, $plugins ?? []); + $client = $pluginClientFactory->createClient($this->client, $plugins); return new HttpMethodsClient( $client, From c85c933e2b076d6b564bb0d8aff2773cd8e57099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 21 Jun 2026 16:26:48 +0100 Subject: [PATCH 88/88] chore(composer): improve package metadata --- composer.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 818d9fd..36ba99b 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,9 @@ { "name": "programmatordev/php-api-sdk", - "description": "A library for creating SDKs in PHP with PSR-18, PSR-17, PSR-6 and PSR-3 support", + "description": "A fluent PHP library for creating API SDKs with PSR-18, PSR-17, PSR-6 and PSR-3 support", "type": "library", - "keywords": ["php-sdk", "php-api", "php8", "psr-18", "psr-17", "psr-6", "psr-3"], + "keywords": ["api", "api-client", "api-sdk", "http-client", "php-api", "php-sdk", "psr-3", "psr-6", "psr-17", "psr-18", "sdk"], + "homepage": "https://github.com/programmatordev/php-api-sdk", "license": "MIT", "authors": [ { @@ -11,6 +12,10 @@ "homepage": "https://programmator.dev" } ], + "support": { + "issues": "https://github.com/programmatordev/php-api-sdk/issues", + "source": "https://github.com/programmatordev/php-api-sdk" + }, "require": { "php": ">=8.1", "ext-simplexml": "*",