From 2113877b7439d616a7bca441d10ebeca1dcb7ace Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 24 Mar 2026 22:01:01 +0100 Subject: [PATCH] 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. --- src/Metadata/ResponseHeaderParameter.php | 19 +++++ .../ResponseHeaderParameterInterface.php | 21 +++++ src/OpenApi/Factory/OpenApiFactory.php | 26 +++++++ src/State/Provider/ParameterProvider.php | 3 +- src/State/Util/HttpResponseHeadersTrait.php | 15 ++++ src/State/Util/ParameterParserTrait.php | 5 ++ .../WithResponseHeaderParameter.php | 78 +++++++++++++++++++ .../ResponseHeaderParameterTest.php | 77 ++++++++++++++++++ 8 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/Metadata/ResponseHeaderParameter.php create mode 100644 src/Metadata/ResponseHeaderParameterInterface.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/WithResponseHeaderParameter.php create mode 100644 tests/Functional/Parameters/ResponseHeaderParameterTest.php diff --git a/src/Metadata/ResponseHeaderParameter.php b/src/Metadata/ResponseHeaderParameter.php new file mode 100644 index 00000000000..8e59f08262b --- /dev/null +++ b/src/Metadata/ResponseHeaderParameter.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +class ResponseHeaderParameter extends Parameter implements ResponseHeaderParameterInterface +{ +} diff --git a/src/Metadata/ResponseHeaderParameterInterface.php b/src/Metadata/ResponseHeaderParameterInterface.php new file mode 100644 index 00000000000..d8ffb799526 --- /dev/null +++ b/src/Metadata/ResponseHeaderParameterInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +/** + * A parameter that documents a HTTP response header. + */ +interface ResponseHeaderParameterInterface +{ +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 351b9d68193..3d030eef759 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\ResponseHeaderParameterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -33,6 +34,7 @@ use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Contact; +use ApiPlatform\OpenApi\Model\Header; use ApiPlatform\OpenApi\Model\Info; use ApiPlatform\OpenApi\Model\License; use ApiPlatform\OpenApi\Model\Link; @@ -315,11 +317,17 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $entityClass = $this->getStateOptionsClass($operation, $operation->getClass()); $openapiParameters = $openapiOperation->getParameters(); + $responseHeaderParameters = []; foreach ($operation->getParameters() ?? [] as $key => $p) { if (false === $p->getOpenApi()) { continue; } + if ($p instanceof ResponseHeaderParameterInterface) { + $responseHeaderParameters[$key] = $p; + continue; + } + if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) { $filter = $this->filterLocator->get($f); @@ -451,6 +459,24 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error')); } + if ($responseHeaderParameters) { + $responseHeaders = new \ArrayObject(); + foreach ($responseHeaderParameters as $key => $p) { + $responseHeaders[$key] = new Header(description: $p->getDescription() ?? '', schema: $p->getSchema() ?? ['type' => 'string']); + } + + foreach ($openapiOperation->getResponses() as $status => $response) { + $existingHeaders = $response->getHeaders(); + $mergedHeaders = $existingHeaders ? clone $existingHeaders : new \ArrayObject(); + foreach ($responseHeaders as $name => $header) { + if (!isset($mergedHeaders[$name])) { + $mergedHeaders[$name] = $header; + } + } + $openapiOperation = $openapiOperation->withResponse($status, $response->withHeaders($mergedHeaders)); + } + } + if ( \in_array($method, ['PATCH', 'PUT', 'POST'], true) && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 05bd072cb55..469cf11b0ae 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ResponseHeaderParameterInterface; use ApiPlatform\State\Exception\ParameterNotSupportedException; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ParameterNotFound; @@ -146,7 +147,7 @@ private function handlePathParameters(HttpOperation $operation, array $uriVariab */ private function callParameterProvider(Operation $operation, Parameter $parameter, mixed $values, array $context): Operation { - if ($parameter->getValue() instanceof ParameterNotFound) { + if ($parameter->getValue() instanceof ParameterNotFound && !$parameter instanceof ResponseHeaderParameterInterface) { return $operation; } diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index e3f4fb01a62..a72c77a0440 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -23,9 +23,11 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\ResponseHeaderParameterInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\ParameterNotFound; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; @@ -68,6 +70,19 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $headers = array_merge($headers, $operationHeaders); } + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + if (!$parameter instanceof ResponseHeaderParameterInterface) { + continue; + } + + $value = $parameter->getValue(); + if ($value instanceof ParameterNotFound || null === $value) { + continue; + } + + $headers[$key] = (string) $value; + } + if ($sunset = $operation->getSunset()) { $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTimeInterface::RFC1123); } diff --git a/src/State/Util/ParameterParserTrait.php b/src/State/Util/ParameterParserTrait.php index bd8a26b950a..190b3e27b5b 100644 --- a/src/State/Util/ParameterParserTrait.php +++ b/src/State/Util/ParameterParserTrait.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ResponseHeaderParameterInterface; use ApiPlatform\State\ParameterNotFound; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -33,6 +34,10 @@ trait ParameterParserTrait */ private function getParameterValues(Parameter $parameter, ?Request $request, array $context): array { + if ($parameter instanceof ResponseHeaderParameterInterface) { + return []; + } + if ($request) { return ($parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters')) ?? []; } diff --git a/tests/Fixtures/TestBundle/ApiResource/WithResponseHeaderParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithResponseHeaderParameter.php new file mode 100644 index 00000000000..31c07ab5325 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/WithResponseHeaderParameter.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\ResponseHeaderParameter; + +#[Get( + uriTemplate: 'with_response_headers/{id}', + parameters: [ + 'RateLimit-Limit' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Maximum number of requests per window', provider: [self::class, 'provideRateLimitHeaders']), + 'RateLimit-Remaining' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Remaining requests in current window', provider: [self::class, 'provideRateLimitHeaders']), + ], + provider: [self::class, 'provide'], +)] +#[Post( + uriTemplate: 'with_response_headers', + parameters: [ + 'RateLimit-Limit' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Maximum number of requests per window'), + 'RateLimit-Remaining' => new ResponseHeaderParameter(schema: ['type' => 'integer'], description: 'Remaining requests in current window'), + ], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], +)] +class WithResponseHeaderParameter +{ + public function __construct(public readonly string $id = '1', public readonly string $name = 'hello') + { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self($uriVariables['id'] ?? '1'); + } + + public static function provideRateLimitHeaders(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + if ('RateLimit-Limit' === $parameter->getKey()) { + $parameter->setValue(100); + } + if ('RateLimit-Remaining' === $parameter->getKey()) { + $parameter->setValue(99); + } + + return $context['operation'] ?? null; + } + + /** + * @param array $context + */ + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + foreach ($operation->getParameters() ?? [] as $parameter) { + if ('RateLimit-Limit' === $parameter->getKey()) { + $parameter->setValue(50); + } + if ('RateLimit-Remaining' === $parameter->getKey()) { + $parameter->setValue(49); + } + } + + return $data; + } +} diff --git a/tests/Functional/Parameters/ResponseHeaderParameterTest.php b/tests/Functional/Parameters/ResponseHeaderParameterTest.php new file mode 100644 index 00000000000..bff8597fa53 --- /dev/null +++ b/tests/Functional/Parameters/ResponseHeaderParameterTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithResponseHeaderParameter; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ResponseHeaderParameterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [WithResponseHeaderParameter::class]; + } + + public function testResponseHeadersAreSet(): void + { + self::createClient()->request('GET', 'with_response_headers/1'); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('ratelimit-limit', '100'); + $this->assertResponseHeaderSame('ratelimit-remaining', '99'); + } + + public function testProcessorSetsResponseHeaders(): void + { + self::createClient()->request('POST', 'with_response_headers', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['id' => '3', 'name' => 'test'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('ratelimit-limit', '50'); + $this->assertResponseHeaderSame('ratelimit-remaining', '49'); + } + + public function testOpenApiDocumentsResponseHeaders(): void + { + $response = self::createClient()->request('GET', 'docs', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); + $this->assertResponseIsSuccessful(); + + $json = $response->toArray(); + + $itemPath = $json['paths']['/with_response_headers/{id}']['get']; + $this->assertArrayHasKey('responses', $itemPath); + + $successResponse = $itemPath['responses']['200'] ?? $itemPath['responses'][200] ?? null; + $this->assertNotNull($successResponse); + $this->assertArrayHasKey('headers', $successResponse); + $this->assertArrayHasKey('RateLimit-Limit', $successResponse['headers']); + $this->assertArrayHasKey('RateLimit-Remaining', $successResponse['headers']); + $this->assertSame('integer', $successResponse['headers']['RateLimit-Limit']['schema']['type']); + $this->assertSame('Maximum number of requests per window', $successResponse['headers']['RateLimit-Limit']['description']); + + // Verify headers are NOT in request parameters + foreach ($itemPath['parameters'] ?? [] as $parameter) { + $this->assertNotSame('RateLimit-Limit', $parameter['name']); + $this->assertNotSame('RateLimit-Remaining', $parameter['name']); + } + } +}