diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index e2b787bd156..e122f577459 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -29,6 +29,7 @@ use ApiPlatform\State\Pagination\PartialPaginatorInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Util\HttpResponseHeadersTrait; +use Psr\Container\ContainerInterface; use ApiPlatform\State\Util\HttpResponseStatusTrait; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -59,11 +60,13 @@ public function __construct( private readonly string $enabledParameterName = 'pagination', private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ContainerInterface $responseHeaderProviderLocator = null, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; $this->operationMetadataFactory = $operationMetadataFactory; $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->responseHeaderProviderLocator = $responseHeaderProviderLocator; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index b910418766d..2e8c6e5e4f4 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -82,6 +82,7 @@ use ApiPlatform\State\ErrorProvider; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ParameterProviderInterface; +use ApiPlatform\State\ResponseHeaderProviderInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\SecurityParameterProvider; @@ -160,6 +161,7 @@ public function register(): void }); $this->autoconfigure($classes, ParameterProviderInterface::class, [SerializerFilterParameterProvider::class, SortFilterParameterProvider::class, SparseFieldsetParameterProvider::class]); + $this->autoconfigure($classes, ResponseHeaderProviderInterface::class, []); $this->app->bind(FilterQueryExtension::class, static function (Application $app) { $tagged = iterator_to_array($app->tagged(EloquentFilterInterface::class)); diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8eac85f2780..de18433eb46 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -165,6 +165,7 @@ use ApiPlatform\State\Processor\WriteProcessor; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Provider\ContentNegotiationProvider; +use ApiPlatform\State\ResponseHeaderProviderInterface; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ObjectMapperProvider; use ApiPlatform\State\Provider\ParameterProvider; @@ -473,11 +474,17 @@ public function register(): void } $this->app->singleton(RespondProcessor::class, static function (Application $app) { + $responseHeaderProviders = []; + foreach ($app->tagged(ResponseHeaderProviderInterface::class) as $provider) { + $responseHeaderProviders[$provider::class] = $provider; + } + $decorated = new RespondProcessor( $app->make(IriConverterInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(OperationMetadataFactoryInterface::class), - $app->make(ResourceMetadataCollectionFactoryInterface::class) + $app->make(ResourceMetadataCollectionFactoryInterface::class), + new ServiceLocator($responseHeaderProviders), ); if (class_exists(AddHeadersProcessor::class)) { diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index b4e55ef6765..338c3838a1b 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -126,6 +127,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index dabe1b854d5..142ef72f374 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -120,6 +121,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 4babd54eb27..eae246150d4 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -125,6 +126,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 27df4b9ad41..7aaf1b493ac 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -126,6 +127,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 58d4cf98c7f..786e0c87c8f 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -70,7 +70,8 @@ class HttpOperation extends Operation * no_transform?: bool, * immutable?: bool, * }|null $cacheHeaders {@see https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers} - * @param array|null $headers + * @param array|null $headers + * @param array|null $responseHeaders * @param listheaders; } + /** + * @return array|null + */ + public function getResponseHeaders(): ?array + { + return $this->responseHeaders; + } + + /** + * @param array $responseHeaders + */ + public function withResponseHeaders(array $responseHeaders): static + { + $self = clone $this; + $self->responseHeaders = $responseHeaders; + + return $self; + } + public function withHeaders(array $headers): static { $self = clone $this; diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php index c36342c1e6b..50e3be52c25 100644 --- a/src/Metadata/McpResource.php +++ b/src/Metadata/McpResource.php @@ -123,6 +123,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -206,6 +207,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php index 3a1d12c44bb..b123b7fe4ac 100644 --- a/src/Metadata/McpTool.php +++ b/src/Metadata/McpTool.php @@ -117,6 +117,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -200,6 +201,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index e106aa23b4e..48b997ee3c7 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -52,6 +52,7 @@ public function __construct( ?string $condition = null, ?string $controller = 'api_platform.action.not_exposed', ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, @@ -132,6 +133,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 13d7dc442a0..723770b9cdb 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -126,6 +127,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 419512a851d..65ea8aee64a 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -127,6 +128,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 3ea21ffeadd..a687ddd4e8e 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -41,6 +41,7 @@ public function __construct( ?string $condition = null, ?string $controller = null, ?array $headers = null, + ?array $responseHeaders = null, ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, @@ -127,6 +128,7 @@ public function __construct( condition: $condition, controller: $controller, headers: $headers, + responseHeaders: $responseHeaders, cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, diff --git a/src/Metadata/ResponseHeader.php b/src/Metadata/ResponseHeader.php new file mode 100644 index 00000000000..e31b5c56837 --- /dev/null +++ b/src/Metadata/ResponseHeader.php @@ -0,0 +1,182 @@ + + * + * 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; + +use ApiPlatform\OpenApi\Model\Header as OpenApiHeader; +use ApiPlatform\State\ResponseHeaderProviderInterface; + +/** + * Declares an HTTP response header for an operation. Used for OpenAPI documentation + * and to set, replace or remove a response header at runtime. + * + * - A static value can be provided through the `$value` argument. + * - A `$provider` (a callable or a service ID resolving to a + * {@see ResponseHeaderProviderInterface}) can compute the value at runtime. + * - Returning `null` from the provider removes the header from the response (an empty + * header value is represented by an empty string). + * - Without `$value` or `$provider`, the header is documented only and not set at runtime. + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class ResponseHeader +{ + /** + * @param array|null $schema + * @param ResponseHeaderProviderInterface|callable|array|string|null $provider + * @param array $extraProperties + */ + public function __construct( + private ?string $key = null, + private ?array $schema = null, + private ?string $description = null, + private string|null $value = null, + private mixed $provider = null, + private ?bool $required = null, + private ?bool $deprecated = null, + private bool|OpenApiHeader|null $openapi = null, + private array $extraProperties = [], + ) { + } + + public function getKey(): ?string + { + return $this->key; + } + + public function withKey(string $key): self + { + $self = clone $this; + $self->key = $key; + + return $self; + } + + /** + * @return array|null + */ + public function getSchema(): ?array + { + return $this->schema; + } + + /** + * @param array $schema + */ + public function withSchema(array $schema): self + { + $self = clone $this; + $self->schema = $schema; + + return $self; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function withDescription(string $description): self + { + $self = clone $this; + $self->description = $description; + + return $self; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function withValue(?string $value): self + { + $self = clone $this; + $self->value = $value; + + return $self; + } + + public function getProvider(): mixed + { + return $this->provider; + } + + /** + * @param ResponseHeaderProviderInterface|callable|array|string|null $provider + */ + public function withProvider(mixed $provider): self + { + $self = clone $this; + $self->provider = $provider; + + return $self; + } + + public function getRequired(): ?bool + { + return $this->required; + } + + public function withRequired(bool $required): self + { + $self = clone $this; + $self->required = $required; + + return $self; + } + + public function getDeprecated(): ?bool + { + return $this->deprecated; + } + + public function withDeprecated(bool $deprecated): self + { + $self = clone $this; + $self->deprecated = $deprecated; + + return $self; + } + + public function getOpenApi(): bool|OpenApiHeader|null + { + return $this->openapi; + } + + public function withOpenApi(bool|OpenApiHeader $openapi): self + { + $self = clone $this; + $self->openapi = $openapi; + + return $self; + } + + /** + * @return array + */ + public function getExtraProperties(): array + { + return $this->extraProperties; + } + + /** + * @param array $extraProperties + */ + public function withExtraProperties(array $extraProperties): self + { + $self = clone $this; + $self->extraProperties = $extraProperties; + + return $self; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 351b9d68193..9989ff3163e 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -33,6 +33,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; @@ -451,6 +452,42 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error')); } + if ($responseHeaders = $operation->getResponseHeaders()) { + /** @var \ArrayObject $documentedHeaders */ + $documentedHeaders = new \ArrayObject(); + foreach ($responseHeaders as $name => $responseHeader) { + if (false === $responseHeader->getOpenApi()) { + continue; + } + + $openapiHeader = $responseHeader->getOpenApi(); + if ($openapiHeader instanceof Header) { + $documentedHeaders[$name] = $openapiHeader; + continue; + } + + $documentedHeaders[$name] = new Header( + description: $responseHeader->getDescription() ?? '', + required: $responseHeader->getRequired() ?? false, + deprecated: $responseHeader->getDeprecated() ?? false, + schema: $responseHeader->getSchema() ?? ['type' => 'string'], + ); + } + + if (\count($documentedHeaders)) { + foreach ($openapiOperation->getResponses() as $status => $response) { + $existingHeaders = $response->getHeaders(); + $mergedHeaders = $existingHeaders ? clone $existingHeaders : new \ArrayObject(); + foreach ($documentedHeaders 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/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index c91b43bcba2..1e6870d309e 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -24,6 +24,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Util\HttpResponseHeadersTrait; use ApiPlatform\State\Util\HttpResponseStatusTrait; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\JsonStreamer\StreamWriterInterface; @@ -48,11 +49,13 @@ public function __construct( ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ContainerInterface $responseHeaderProviderLocator = null, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; $this->operationMetadataFactory = $operationMetadataFactory; $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->responseHeaderProviderLocator = $responseHeaderProviderLocator; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index dff832858ff..b119112e87c 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -24,6 +24,7 @@ use ApiPlatform\State\StopwatchAwareTrait; use ApiPlatform\State\Util\HttpResponseHeadersTrait; use ApiPlatform\State\Util\HttpResponseStatusTrait; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Response; /** @@ -42,11 +43,13 @@ public function __construct( ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ContainerInterface $responseHeaderProviderLocator = null, ) { $this->iriConverter = $iriConverter; $this->resourceClassResolver = $resourceClassResolver; $this->operationMetadataFactory = $operationMetadataFactory; $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->responseHeaderProviderLocator = $responseHeaderProviderLocator; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/State/ResponseHeaderProviderInterface.php b/src/State/ResponseHeaderProviderInterface.php new file mode 100644 index 00000000000..d29ff8b27c4 --- /dev/null +++ b/src/State/ResponseHeaderProviderInterface.php @@ -0,0 +1,33 @@ + + * + * 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\State; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\ResponseHeader; + +/** + * Resolves the runtime value of a {@see ResponseHeader}. + * + * Returning `null` removes the header from the response. Use an empty string to + * produce an empty header value. + */ +interface ResponseHeaderProviderInterface +{ + /** + * @param array $context + * + * @return string|array|null + */ + public function provide(ResponseHeader $header, HttpOperation $operation, array $context = []): string|array|null; +} diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index e3f4fb01a62..68eeebbbe23 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -23,9 +23,12 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\ResponseHeader; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\ResponseHeaderProviderInterface; +use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; @@ -43,6 +46,7 @@ trait HttpResponseHeadersTrait private ?OperationMetadataFactoryInterface $operationMetadataFactory; private ?ResourceClassResolverInterface $resourceClassResolver; private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory; + private ?ContainerInterface $responseHeaderProviderLocator = null; /** * @param array $context @@ -135,9 +139,56 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $this->addLinkedDataPlatformHeaders($headers, $operation); } + foreach ($operation->getResponseHeaders() ?? [] as $name => $responseHeader) { + if (null === $responseHeader->getKey()) { + $responseHeader = $responseHeader->withKey($name); + } + + $resolved = $this->resolveResponseHeader($responseHeader, $operation, $context); + if (null === $resolved) { + unset($headers[$name]); + continue; + } + + $headers[$name] = $resolved; + } + return $headers; } + /** + * @param array $context + * + * @return string|array|null + */ + private function resolveResponseHeader(ResponseHeader $header, HttpOperation $operation, array $context): string|array|null + { + $provider = $header->getProvider(); + + if (null === $provider) { + return $header->getValue(); + } + + if (\is_string($provider) && $this->responseHeaderProviderLocator?->has($provider)) { + $service = $this->responseHeaderProviderLocator->get($provider); + if (!$service instanceof ResponseHeaderProviderInterface) { + throw new RuntimeException(\sprintf('Service "%s" must implement "%s".', $provider, ResponseHeaderProviderInterface::class)); + } + + return $service->provide($header, $operation, $context); + } + + if ($provider instanceof ResponseHeaderProviderInterface) { + return $provider->provide($header, $operation, $context); + } + + if (\is_callable($provider)) { + return $provider($header, $operation, $context); + } + + throw new RuntimeException(\sprintf('Response header "%s" provider is not callable nor a registered "%s" service.', $header->getKey() ?? '', ResponseHeaderProviderInterface::class)); + } + private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void { if (!$this->resourceMetadataCollectionFactory) { diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 01c696c4e74..6631fbd23a2 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -60,6 +60,7 @@ use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\ResponseHeaderProviderInterface; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Validator\Exception\ValidationException; @@ -224,6 +225,8 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('api_platform.uri_variables.transformer'); $container->registerForAutoconfiguration(ParameterProviderInterface::class) ->addTag('api_platform.parameter_provider'); + $container->registerForAutoconfiguration(ResponseHeaderProviderInterface::class) + ->addTag('api_platform.response_header_provider'); $container->registerAttributeForAutoconfiguration( AsResourceMutator::class, diff --git a/src/Symfony/Bundle/Resources/config/state/processor.php b/src/Symfony/Bundle/Resources/config/state/processor.php index f44dfb20d8f..47136633761 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -44,6 +44,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + tagged_locator('api_platform.response_header_provider', 'key'), ]); $services->set('api_platform.state_processor.add_link_header', AddLinkHeaderProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e70..6d595c5ecfb 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -104,6 +104,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + tagged_locator('api_platform.response_header_provider', 'key'), ]); $services->set('api_platform.state_processor.add_link_header', AddLinkHeaderProcessor::class) diff --git a/tests/Fixtures/TestBundle/ApiResource/WithResponseHeader.php b/tests/Fixtures/TestBundle/ApiResource/WithResponseHeader.php new file mode 100644 index 00000000000..93140452de3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/WithResponseHeader.php @@ -0,0 +1,73 @@ + + * + * 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\Post; +use ApiPlatform\Metadata\ResponseHeader; +use ApiPlatform\Tests\Fixtures\TestBundle\Parameter\RateLimitHeaderProvider; + +#[Get( + uriTemplate: 'with_response_headers/{id}', + responseHeaders: [ + 'RateLimit-Limit' => new ResponseHeader( + schema: ['type' => 'integer'], + description: 'Maximum number of requests per window', + provider: RateLimitHeaderProvider::class, + ), + 'RateLimit-Remaining' => new ResponseHeader( + schema: ['type' => 'integer'], + description: 'Remaining requests in current window', + provider: RateLimitHeaderProvider::class, + ), + 'X-Static-Header' => new ResponseHeader( + value: 'static-value', + schema: ['type' => 'string'], + description: 'Static header value', + ), + 'X-Frame-Options' => new ResponseHeader( + description: 'Cleared by callable provider', + provider: [self::class, 'clearHeader'], + ), + ], + provider: [self::class, 'provide'], +)] +#[Post( + uriTemplate: 'with_response_headers', + responseHeaders: [ + 'RateLimit-Limit' => new ResponseHeader( + schema: ['type' => 'integer'], + description: 'Maximum number of requests per window', + provider: RateLimitHeaderProvider::class, + ), + ], + provider: [self::class, 'provide'], +)] +class WithResponseHeader +{ + 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 clearHeader(): null + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/Parameter/RateLimitHeaderProvider.php b/tests/Fixtures/TestBundle/Parameter/RateLimitHeaderProvider.php new file mode 100644 index 00000000000..3895d0ec881 --- /dev/null +++ b/tests/Fixtures/TestBundle/Parameter/RateLimitHeaderProvider.php @@ -0,0 +1,30 @@ + + * + * 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\Parameter; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\ResponseHeader; +use ApiPlatform\State\ResponseHeaderProviderInterface; + +final class RateLimitHeaderProvider implements ResponseHeaderProviderInterface +{ + public function provide(ResponseHeader $header, HttpOperation $operation, array $context = []): string|array|null + { + return match ($header->getKey()) { + 'RateLimit-Limit' => '100', + 'RateLimit-Remaining' => '99', + default => null, + }; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1320c1e2637..3933909bb55 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -495,6 +495,11 @@ services: - name: 'api_platform.parameter_provider' key: 'ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider' + ApiPlatform\Tests\Fixtures\TestBundle\Parameter\RateLimitHeaderProvider: + tags: + - name: 'api_platform.response_header_provider' + key: 'ApiPlatform\Tests\Fixtures\TestBundle\Parameter\RateLimitHeaderProvider' + app.graphql.mutation_resolver.activity_log: class: 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6354\CreateActivityLogResolver' tags: diff --git a/tests/Functional/ResponseHeaderTest.php b/tests/Functional/ResponseHeaderTest.php new file mode 100644 index 00000000000..7eab03e9fec --- /dev/null +++ b/tests/Functional/ResponseHeaderTest.php @@ -0,0 +1,80 @@ + + * + * 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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithResponseHeader; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ResponseHeaderTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [WithResponseHeader::class]; + } + + public function testServiceProviderSetsResponseHeaders(): void + { + self::createClient()->request('GET', 'with_response_headers/1'); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('ratelimit-limit', '100'); + $this->assertResponseHeaderSame('ratelimit-remaining', '99'); + } + + public function testStaticResponseHeader(): void + { + self::createClient()->request('GET', 'with_response_headers/1'); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('x-static-header', 'static-value'); + } + + public function testCallableProviderReturningNullRemovesHeader(): void + { + self::createClient()->request('GET', 'with_response_headers/1'); + $this->assertResponseIsSuccessful(); + $this->assertResponseNotHasHeader('x-frame-options'); + } + + 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->assertArrayHasKey('X-Static-Header', $successResponse['headers']); + $this->assertSame('integer', $successResponse['headers']['RateLimit-Limit']['schema']['type']); + $this->assertSame('Maximum number of requests per window', $successResponse['headers']['RateLimit-Limit']['description']); + + foreach ($itemPath['parameters'] ?? [] as $parameter) { + $this->assertNotSame('RateLimit-Limit', $parameter['name']); + $this->assertNotSame('RateLimit-Remaining', $parameter['name']); + $this->assertNotSame('X-Static-Header', $parameter['name']); + } + } +}