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']); + } + } +}