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