Skip to content

Commit 2113877

Browse files
committed
feat(metadata): ResponseHeaderParameter for declaring HTTP response headers
Introduces ResponseHeaderParameterInterface and ResponseHeaderParameter attribute to declare response headers as part of operation parameters. Headers are set via parameter providers (read) or processors (write), documented in OpenAPI output, and applied to HTTP responses.
1 parent 7c83f85 commit 2113877

File tree

8 files changed

+243
-1
lines changed

8 files changed

+243
-1
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata;
15+
16+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
17+
class ResponseHeaderParameter extends Parameter implements ResponseHeaderParameterInterface
18+
{
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Metadata;
15+
16+
/**
17+
* A parameter that documents a HTTP response header.
18+
*/
19+
interface ResponseHeaderParameterInterface
20+
{
21+
}

src/OpenApi/Factory/OpenApiFactory.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ApiPlatform\Metadata\Exception\RuntimeException;
2626
use ApiPlatform\Metadata\HeaderParameterInterface;
2727
use ApiPlatform\Metadata\HttpOperation;
28+
use ApiPlatform\Metadata\ResponseHeaderParameterInterface;
2829
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2930
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
3031
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -33,6 +34,7 @@
3334
use ApiPlatform\OpenApi\Attributes\Webhook;
3435
use ApiPlatform\OpenApi\Model\Components;
3536
use ApiPlatform\OpenApi\Model\Contact;
37+
use ApiPlatform\OpenApi\Model\Header;
3638
use ApiPlatform\OpenApi\Model\Info;
3739
use ApiPlatform\OpenApi\Model\License;
3840
use ApiPlatform\OpenApi\Model\Link;
@@ -315,11 +317,17 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
315317

316318
$entityClass = $this->getStateOptionsClass($operation, $operation->getClass());
317319
$openapiParameters = $openapiOperation->getParameters();
320+
$responseHeaderParameters = [];
318321
foreach ($operation->getParameters() ?? [] as $key => $p) {
319322
if (false === $p->getOpenApi()) {
320323
continue;
321324
}
322325

326+
if ($p instanceof ResponseHeaderParameterInterface) {
327+
$responseHeaderParameters[$key] = $p;
328+
continue;
329+
}
330+
323331
if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) {
324332
$filter = $this->filterLocator->get($f);
325333

@@ -451,6 +459,24 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
451459
$openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error'));
452460
}
453461

462+
if ($responseHeaderParameters) {
463+
$responseHeaders = new \ArrayObject();
464+
foreach ($responseHeaderParameters as $key => $p) {
465+
$responseHeaders[$key] = new Header(description: $p->getDescription() ?? '', schema: $p->getSchema() ?? ['type' => 'string']);
466+
}
467+
468+
foreach ($openapiOperation->getResponses() as $status => $response) {
469+
$existingHeaders = $response->getHeaders();
470+
$mergedHeaders = $existingHeaders ? clone $existingHeaders : new \ArrayObject();
471+
foreach ($responseHeaders as $name => $header) {
472+
if (!isset($mergedHeaders[$name])) {
473+
$mergedHeaders[$name] = $header;
474+
}
475+
}
476+
$openapiOperation = $openapiOperation->withResponse($status, $response->withHeaders($mergedHeaders));
477+
}
478+
}
479+
454480
if (
455481
\in_array($method, ['PATCH', 'PUT', 'POST'], true)
456482
&& !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class']))

src/State/Provider/ParameterProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Metadata\HttpOperation;
1717
use ApiPlatform\Metadata\Operation;
1818
use ApiPlatform\Metadata\Parameter;
19+
use ApiPlatform\Metadata\ResponseHeaderParameterInterface;
1920
use ApiPlatform\State\Exception\ParameterNotSupportedException;
2021
use ApiPlatform\State\Exception\ProviderNotFoundException;
2122
use ApiPlatform\State\ParameterNotFound;
@@ -146,7 +147,7 @@ private function handlePathParameters(HttpOperation $operation, array $uriVariab
146147
*/
147148
private function callParameterProvider(Operation $operation, Parameter $parameter, mixed $values, array $context): Operation
148149
{
149-
if ($parameter->getValue() instanceof ParameterNotFound) {
150+
if ($parameter->getValue() instanceof ParameterNotFound && !$parameter instanceof ResponseHeaderParameterInterface) {
150151
return $operation;
151152
}
152153

src/State/Util/HttpResponseHeadersTrait.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
2424
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2525
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26+
use ApiPlatform\Metadata\ResponseHeaderParameterInterface;
2627
use ApiPlatform\Metadata\UrlGeneratorInterface;
2728
use ApiPlatform\Metadata\Util\ClassInfoTrait;
2829
use ApiPlatform\Metadata\Util\CloneTrait;
30+
use ApiPlatform\State\ParameterNotFound;
2931
use Symfony\Component\HttpFoundation\Request;
3032
use Symfony\Component\HttpFoundation\Response;
3133
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface;
@@ -68,6 +70,19 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
6870
$headers = array_merge($headers, $operationHeaders);
6971
}
7072

73+
foreach ($operation->getParameters() ?? [] as $key => $parameter) {
74+
if (!$parameter instanceof ResponseHeaderParameterInterface) {
75+
continue;
76+
}
77+
78+
$value = $parameter->getValue();
79+
if ($value instanceof ParameterNotFound || null === $value) {
80+
continue;
81+
}
82+
83+
$headers[$key] = (string) $value;
84+
}
85+
7186
if ($sunset = $operation->getSunset()) {
7287
$headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTimeInterface::RFC1123);
7388
}

src/State/Util/ParameterParserTrait.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Metadata\HeaderParameter;
1717
use ApiPlatform\Metadata\HeaderParameterInterface;
1818
use ApiPlatform\Metadata\Parameter;
19+
use ApiPlatform\Metadata\ResponseHeaderParameterInterface;
1920
use ApiPlatform\State\ParameterNotFound;
2021
use Symfony\Component\HttpFoundation\Request;
2122
use Symfony\Component\TypeInfo\Type\CollectionType;
@@ -33,6 +34,10 @@ trait ParameterParserTrait
3334
*/
3435
private function getParameterValues(Parameter $parameter, ?Request $request, array $context): array
3536
{
37+
if ($parameter instanceof ResponseHeaderParameterInterface) {
38+
return [];
39+
}
40+
3641
if ($request) {
3742
return ($parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters')) ?? [];
3843
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\Get;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\Metadata\Parameter;
19+
use ApiPlatform\Metadata\Post;
20+
use ApiPlatform\Metadata\ResponseHeaderParameter;
21+
22+
#[Get(
23+
uriTemplate: 'with_response_headers/{id}',
24+
parameters: [
25+
'RateLimit-Limit' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Maximum number of requests per window', provider: [self::class, 'provideRateLimitHeaders']),
26+
'RateLimit-Remaining' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Remaining requests in current window', provider: [self::class, 'provideRateLimitHeaders']),
27+
],
28+
provider: [self::class, 'provide'],
29+
)]
30+
#[Post(
31+
uriTemplate: 'with_response_headers',
32+
parameters: [
33+
'RateLimit-Limit' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Maximum number of requests per window'),
34+
'RateLimit-Remaining' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Remaining requests in current window'),
35+
],
36+
provider: [self::class, 'provide'],
37+
processor: [self::class, 'process'],
38+
)]
39+
class WithResponseHeaderParameter
40+
{
41+
public function __construct(public readonly string $id = '1', public readonly string $name = 'hello')
42+
{
43+
}
44+
45+
public static function provide(Operation $operation, array $uriVariables = []): self
46+
{
47+
return new self($uriVariables['id'] ?? '1');
48+
}
49+
50+
public static function provideRateLimitHeaders(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
51+
{
52+
if ('RateLimit-Limit' === $parameter->getKey()) {
53+
$parameter->setValue(100);
54+
}
55+
if ('RateLimit-Remaining' === $parameter->getKey()) {
56+
$parameter->setValue(99);
57+
}
58+
59+
return $context['operation'] ?? null;
60+
}
61+
62+
/**
63+
* @param array<string, mixed> $context
64+
*/
65+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
66+
{
67+
foreach ($operation->getParameters() ?? [] as $parameter) {
68+
if ('RateLimit-Limit' === $parameter->getKey()) {
69+
$parameter->setValue(50);
70+
}
71+
if ('RateLimit-Remaining' === $parameter->getKey()) {
72+
$parameter->setValue(49);
73+
}
74+
}
75+
76+
return $data;
77+
}
78+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithResponseHeaderParameter;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
20+
final class ResponseHeaderParameterTest extends ApiTestCase
21+
{
22+
use SetupClassResourcesTrait;
23+
24+
protected static ?bool $alwaysBootKernel = false;
25+
26+
/**
27+
* @return class-string[]
28+
*/
29+
public static function getResources(): array
30+
{
31+
return [WithResponseHeaderParameter::class];
32+
}
33+
34+
public function testResponseHeadersAreSet(): void
35+
{
36+
self::createClient()->request('GET', 'with_response_headers/1');
37+
$this->assertResponseIsSuccessful();
38+
$this->assertResponseHeaderSame('ratelimit-limit', '100');
39+
$this->assertResponseHeaderSame('ratelimit-remaining', '99');
40+
}
41+
42+
public function testProcessorSetsResponseHeaders(): void
43+
{
44+
self::createClient()->request('POST', 'with_response_headers', [
45+
'headers' => ['Content-Type' => 'application/ld+json'],
46+
'json' => ['id' => '3', 'name' => 'test'],
47+
]);
48+
$this->assertResponseIsSuccessful();
49+
$this->assertResponseHeaderSame('ratelimit-limit', '50');
50+
$this->assertResponseHeaderSame('ratelimit-remaining', '49');
51+
}
52+
53+
public function testOpenApiDocumentsResponseHeaders(): void
54+
{
55+
$response = self::createClient()->request('GET', 'docs', ['headers' => ['Accept' => 'application/vnd.openapi+json']]);
56+
$this->assertResponseIsSuccessful();
57+
58+
$json = $response->toArray();
59+
60+
$itemPath = $json['paths']['/with_response_headers/{id}']['get'];
61+
$this->assertArrayHasKey('responses', $itemPath);
62+
63+
$successResponse = $itemPath['responses']['200'] ?? $itemPath['responses'][200] ?? null;
64+
$this->assertNotNull($successResponse);
65+
$this->assertArrayHasKey('headers', $successResponse);
66+
$this->assertArrayHasKey('RateLimit-Limit', $successResponse['headers']);
67+
$this->assertArrayHasKey('RateLimit-Remaining', $successResponse['headers']);
68+
$this->assertSame('integer', $successResponse['headers']['RateLimit-Limit']['schema']['type']);
69+
$this->assertSame('Maximum number of requests per window', $successResponse['headers']['RateLimit-Limit']['description']);
70+
71+
// Verify headers are NOT in request parameters
72+
foreach ($itemPath['parameters'] ?? [] as $parameter) {
73+
$this->assertNotSame('RateLimit-Limit', $parameter['name']);
74+
$this->assertNotSame('RateLimit-Remaining', $parameter['name']);
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)