diff --git a/README.md b/README.md index 51b1ee1..68f9cb8 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,9 @@ stixx_openapi_command: validation: enabled: true groups: ['Default'] + cache_control: 'no-store' # Any valid Cache-Control directives, or null to disable openapi: - problem_details: true # Enable RFC 7807 problem details for errors + problem_details: true # Enable RFC 7807 problem details for errors ``` ## Documentation diff --git a/config/controller.php b/config/controller.php index 5e404e4..b1936f9 100644 --- a/config/controller.php +++ b/config/controller.php @@ -28,7 +28,8 @@ ->arg('$responder', service(ResponderInterface::class)) ->arg('$exceptionUnwrapper', service(WrappedExceptionUnwrapper::class)) ->arg('$validationEnabled', param('stixx_openapi_command.validation.enabled')) - ->arg('$validationGroups', param('stixx_openapi_command.validation.groups')); + ->arg('$validationGroups', param('stixx_openapi_command.validation.groups')) + ->arg('$cacheControl', param('stixx_openapi_command.cache_control')); $services ->set(CommandValueResolver::class) diff --git a/src/Controller/CommandController.php b/src/Controller/CommandController.php index a47f252..f5705f5 100644 --- a/src/Controller/CommandController.php +++ b/src/Controller/CommandController.php @@ -41,6 +41,7 @@ public function __construct( private bool $validationEnabled = true, /** @var string[] */ private array $validationGroups = ['Default'], + private ?string $cacheControl = 'no-store', ) { } @@ -63,7 +64,13 @@ public function __invoke(Request $request, #[CommandObject] object $command): Re $handled = $envelope->last(HandledStamp::class); $result = $handled?->getResult(); - return $this->responder->respond($result, $this->statusResolver->resolve($request, $command)); + $response = $this->responder->respond($result, $this->statusResolver->resolve($request, $command)); + + if (!empty($this->cacheControl)) { + $response->headers->set('Cache-Control', $this->cacheControl); + } + + return $response; } private function validateCommand(object $command): void diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 7e193cd..1f48bd3 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -42,6 +42,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->scalarNode('cache_control') + ->defaultValue('no-store') + ->end() ->arrayNode('openapi') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/StixxOpenApiCommandExtension.php b/src/DependencyInjection/StixxOpenApiCommandExtension.php index 648e46e..615d38e 100644 --- a/src/DependencyInjection/StixxOpenApiCommandExtension.php +++ b/src/DependencyInjection/StixxOpenApiCommandExtension.php @@ -83,6 +83,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('stixx_openapi_command.validation.enabled', $validationConfig['enabled']); $container->setParameter('stixx_openapi_command.validation.groups', $validationConfig['groups']); + /** @var ?string $cacheControl */ + $cacheControl = $config['cache_control']; + $container->setParameter('stixx_openapi_command.cache_control', $cacheControl); $container ->registerForAutoconfiguration(ResponderInterface::class) diff --git a/tests/Functional/Resources/config/cache_control_custom.php b/tests/Functional/Resources/config/cache_control_custom.php new file mode 100644 index 0000000..91df26c --- /dev/null +++ b/tests/Functional/Resources/config/cache_control_custom.php @@ -0,0 +1,20 @@ +extension('stixx_openapi_command', [ + 'cache_control' => 'no-cache, must-revalidate', + ]); +}; diff --git a/tests/Functional/Resources/config/cache_control_disabled.php b/tests/Functional/Resources/config/cache_control_disabled.php new file mode 100644 index 0000000..cb8fdff --- /dev/null +++ b/tests/Functional/Resources/config/cache_control_disabled.php @@ -0,0 +1,20 @@ +extension('stixx_openapi_command', [ + 'cache_control' => null, + ]); +}; diff --git a/tests/Functional/ScenarioTest.php b/tests/Functional/ScenarioTest.php index 3ef835e..840c62a 100644 --- a/tests/Functional/ScenarioTest.php +++ b/tests/Functional/ScenarioTest.php @@ -169,6 +169,69 @@ public function testUpdateBookValidationError(): void self::assertArrayHasKey('violations', $data); } + #[WithoutErrorHandler] + public function testResponseIncludesDefaultCacheControlHeader(): void + { + // Arrange + $kernel = $this->createKernelWithConfig(static function (Kernel $kernel): void { + $kernel->addTestConfig(__DIR__.'/Resources/config/scenario.php'); + }); + + $payload = ['title' => 'Clean Code', 'author' => 'Robert C. Martin']; + $request = Request::create('/api/books', 'POST', content: json_encode($payload, JSON_THROW_ON_ERROR)); + $request->headers->set('Content-Type', 'application/json'); + + // Act + $response = $kernel->handle($request); + + // Assert + self::assertSame(201, $response->getStatusCode()); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + #[WithoutErrorHandler] + public function testResponseOmitsCacheControlWhenDisabled(): void + { + // Arrange + $kernel = $this->createKernelWithConfig(static function (Kernel $kernel): void { + $kernel->addTestConfig(__DIR__.'/Resources/config/scenario.php'); + $kernel->addTestConfig(__DIR__.'/Resources/config/cache_control_disabled.php'); + }); + + $payload = ['title' => 'Clean Code', 'author' => 'Robert C. Martin']; + $request = Request::create('/api/books', 'POST', content: json_encode($payload, JSON_THROW_ON_ERROR)); + $request->headers->set('Content-Type', 'application/json'); + + // Act + $response = $kernel->handle($request); + + // Assert + self::assertSame(201, $response->getStatusCode()); + self::assertStringNotContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + #[WithoutErrorHandler] + public function testResponseIncludesCustomCacheControlHeader(): void + { + // Arrange + $kernel = $this->createKernelWithConfig(static function (Kernel $kernel): void { + $kernel->addTestConfig(__DIR__.'/Resources/config/scenario.php'); + $kernel->addTestConfig(__DIR__.'/Resources/config/cache_control_custom.php'); + }); + + $payload = ['title' => 'Clean Code', 'author' => 'Robert C. Martin']; + $request = Request::create('/api/books', 'POST', content: json_encode($payload, JSON_THROW_ON_ERROR)); + $request->headers->set('Content-Type', 'application/json'); + + // Act + $response = $kernel->handle($request); + + // Assert + self::assertSame(201, $response->getStatusCode()); + self::assertStringContainsString('no-cache', (string) $response->headers->get('Cache-Control')); + self::assertStringContainsString('must-revalidate', (string) $response->headers->get('Cache-Control')); + } + #[WithoutErrorHandler] public function testUnknownApiPathReturnsProblemJson(): void { diff --git a/tests/Unit/Controller/CommandControllerTest.php b/tests/Unit/Controller/CommandControllerTest.php index d701f43..e83cae3 100644 --- a/tests/Unit/Controller/CommandControllerTest.php +++ b/tests/Unit/Controller/CommandControllerTest.php @@ -151,6 +151,99 @@ public function testInvokeThrowsApiProblemExceptionWhenValidationFails(): void } } + public function testInvokeSetsDefaultCacheControlHeader(): void + { + // Arrange + $command = new ExampleCommand(); + $request = new Request(); + + $validator = $this->createMock(ValidatorInterface::class); + $violations = $this->createMock(ConstraintViolationListInterface::class); + $violations->method('count')->willReturn(0); + $validator->method('validate')->willReturn($violations); + + $result = ['ok' => true]; + $envelope = new Envelope($command, [new HandledStamp($result, 'handler')]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->method('dispatch')->willReturn($envelope); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $statusResolver->method('resolve')->willReturn(200); + + $responder = $this->createMock(ResponderInterface::class); + $responder->method('respond')->willReturn(new Response((string) json_encode($result), 200)); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder, new WrappedExceptionUnwrapper()); + $response = $controller($request, $command); + + // Assert — Symfony's ResponseHeaderBag appends 'private' when no 'public' directive is present + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + public function testInvokeOmitsCacheControlHeaderWhenDisabled(): void + { + // Arrange + $command = new ExampleCommand(); + $request = new Request(); + + $validator = $this->createMock(ValidatorInterface::class); + $violations = $this->createMock(ConstraintViolationListInterface::class); + $violations->method('count')->willReturn(0); + $validator->method('validate')->willReturn($violations); + + $result = ['ok' => true]; + $envelope = new Envelope($command, [new HandledStamp($result, 'handler')]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->method('dispatch')->willReturn($envelope); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $statusResolver->method('resolve')->willReturn(200); + + $responder = $this->createMock(ResponderInterface::class); + $responder->method('respond')->willReturn(new Response((string) json_encode($result), 200)); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder, new WrappedExceptionUnwrapper(), cacheControl: null); + $response = $controller($request, $command); + + // Assert — Symfony sets a computed default; the controller must not override it + self::assertStringNotContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + public function testInvokeSetsCustomCacheControlHeader(): void + { + // Arrange + $command = new ExampleCommand(); + $request = new Request(); + + $validator = $this->createMock(ValidatorInterface::class); + $violations = $this->createMock(ConstraintViolationListInterface::class); + $violations->method('count')->willReturn(0); + $validator->method('validate')->willReturn($violations); + + $result = ['ok' => true]; + $envelope = new Envelope($command, [new HandledStamp($result, 'handler')]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->method('dispatch')->willReturn($envelope); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $statusResolver->method('resolve')->willReturn(200); + + $responder = $this->createMock(ResponderInterface::class); + $responder->method('respond')->willReturn(new Response((string) json_encode($result), 200)); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder, new WrappedExceptionUnwrapper(), cacheControl: 'no-cache, private'); + $response = $controller($request, $command); + + // Assert + self::assertSame('no-cache, private', $response->headers->get('Cache-Control')); + } + public function testInvokeRethrowsPreviousExceptionFromHandlerFailedException(): void { // Arrange diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index 556c821..48dfc38 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -34,6 +34,7 @@ public function testDefaultConfig(): void 'enabled' => true, 'groups' => ['Default'], ], + 'cache_control' => 'no-store', 'openapi' => [ 'problem_details' => true, ], @@ -62,6 +63,7 @@ public function testCustomConfig(): void 'enabled' => false, 'groups' => ['Custom', 'Special'], ], + 'cache_control' => 'no-store', 'openapi' => [ 'problem_details' => true, ],