From 2cf933e60c4056f8c7cc6b8cfabbc0f71497ef00 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 16 Feb 2021 14:07:31 +0100 Subject: [PATCH] PSR HTTP message converters for controllers --- .gitignore | 1 + .../PsrServerRequestResolver.php | 49 ++++++++++++ CHANGELOG.md | 5 ++ EventListener/PsrResponseListener.php | 49 ++++++++++++ .../PsrServerRequestResolverTest.php | 59 +++++++++++++++ .../EventListener/PsrResponseListenerTest.php | 45 +++++++++++ .../App/Controller/PsrRequestController.php | 45 +++++++++++ Tests/Fixtures/App/Kernel.php | 75 +++++++++++++++++++ Tests/Fixtures/App/Kernel44.php | 66 ++++++++++++++++ Tests/Functional/ControllerTest.php | 46 ++++++++++++ composer.json | 11 ++- 11 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 ArgumentValueResolver/PsrServerRequestResolver.php create mode 100644 EventListener/PsrResponseListener.php create mode 100644 Tests/ArgumentValueResolver/PsrServerRequestResolverTest.php create mode 100644 Tests/EventListener/PsrResponseListenerTest.php create mode 100644 Tests/Fixtures/App/Controller/PsrRequestController.php create mode 100644 Tests/Fixtures/App/Kernel.php create mode 100644 Tests/Fixtures/App/Kernel44.php create mode 100644 Tests/Functional/ControllerTest.php diff --git a/.gitignore b/.gitignore index 082fd22..55ce5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock phpunit.xml .php_cs.cache .phpunit.result.cache +/Tests/Fixtures/App/var diff --git a/ArgumentValueResolver/PsrServerRequestResolver.php b/ArgumentValueResolver/PsrServerRequestResolver.php new file mode 100644 index 0000000..094f0c8 --- /dev/null +++ b/ArgumentValueResolver/PsrServerRequestResolver.php @@ -0,0 +1,49 @@ + + * @author Alexander M. Turek + */ +final class PsrServerRequestResolver implements ArgumentValueResolverInterface +{ + private const SUPPORTED_TYPES = [ + ServerRequestInterface::class => true, + RequestInterface::class => true, + MessageInterface::class => true, + ]; + + private $httpMessageFactory; + + public function __construct(HttpMessageFactoryInterface $httpMessageFactory) + { + $this->httpMessageFactory = $httpMessageFactory; + } + + /** + * {@inheritdoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + return self::SUPPORTED_TYPES[$argument->getType()] ?? false; + } + + /** + * {@inheritdoc} + */ + public function resolve(Request $request, ArgumentMetadata $argument): \Traversable + { + yield $this->httpMessageFactory->createRequest($request); + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f3e55..18b2214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +# 2.1.0 (TBD) + + * Added a `PsrResponseListener` to automatically convert PSR-7 responses returned by controllers + * Added a `PsrServerRequestResolver` that allows injecting PSR-7 request objects into controllers + # 2.0.2 (2020-09-29) * Fix populating server params from URI in HttpFoundationFactory diff --git a/EventListener/PsrResponseListener.php b/EventListener/PsrResponseListener.php new file mode 100644 index 0000000..904bb64 --- /dev/null +++ b/EventListener/PsrResponseListener.php @@ -0,0 +1,49 @@ + + * @author Alexander M. Turek + */ +final class PsrResponseListener implements EventSubscriberInterface +{ + private $httpFoundationFactory; + + public function __construct(HttpFoundationFactoryInterface $httpFoundationFactory) + { + $this->httpFoundationFactory = $httpFoundationFactory; + } + + /** + * Do the conversion if applicable and update the response of the event. + */ + public function onKernelView(ViewEvent $event): void + { + $controllerResult = $event->getControllerResult(); + + if (!$controllerResult instanceof ResponseInterface) { + return; + } + + $event->setResponse($this->httpFoundationFactory->createResponse($controllerResult)); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::VIEW => 'onKernelView', + ]; + } +} diff --git a/Tests/ArgumentValueResolver/PsrServerRequestResolverTest.php b/Tests/ArgumentValueResolver/PsrServerRequestResolverTest.php new file mode 100644 index 0000000..1c0973a --- /dev/null +++ b/Tests/ArgumentValueResolver/PsrServerRequestResolverTest.php @@ -0,0 +1,59 @@ + + */ +final class PsrServerRequestResolverTest extends TestCase +{ + public function testServerRequest() + { + $symfonyRequest = $this->createMock(Request::class); + $psrRequest = $this->createMock(ServerRequestInterface::class); + + $resolver = $this->bootstrapResolver($symfonyRequest, $psrRequest); + + self::assertSame([$psrRequest], $resolver->getArguments($symfonyRequest, static function (ServerRequestInterface $serverRequest): void {})); + } + + public function testRequest() + { + $symfonyRequest = $this->createMock(Request::class); + $psrRequest = $this->createMock(ServerRequestInterface::class); + + $resolver = $this->bootstrapResolver($symfonyRequest, $psrRequest); + + self::assertSame([$psrRequest], $resolver->getArguments($symfonyRequest, static function (RequestInterface $request): void {})); + } + + public function testMessage() + { + $symfonyRequest = $this->createMock(Request::class); + $psrRequest = $this->createMock(ServerRequestInterface::class); + + $resolver = $this->bootstrapResolver($symfonyRequest, $psrRequest); + + self::assertSame([$psrRequest], $resolver->getArguments($symfonyRequest, static function (MessageInterface $request): void {})); + } + + private function bootstrapResolver(Request $symfonyRequest, ServerRequestInterface $psrRequest): ArgumentResolver + { + $messageFactory = $this->createMock(HttpMessageFactoryInterface::class); + $messageFactory->expects(self::once()) + ->method('createRequest') + ->with(self::identicalTo($symfonyRequest)) + ->willReturn($psrRequest); + + return new ArgumentResolver(null, [new PsrServerRequestResolver($messageFactory)]); + } +} diff --git a/Tests/EventListener/PsrResponseListenerTest.php b/Tests/EventListener/PsrResponseListenerTest.php new file mode 100644 index 0000000..cd13e27 --- /dev/null +++ b/Tests/EventListener/PsrResponseListenerTest.php @@ -0,0 +1,45 @@ + + */ +class PsrResponseListenerTest extends TestCase +{ + public function testConvertsControllerResult() + { + $listener = new PsrResponseListener(new HttpFoundationFactory()); + $event = $this->createEventMock(new Response()); + $listener->onKernelView($event); + + self::assertTrue($event->hasResponse()); + } + + public function testDoesNotConvertControllerResult() + { + $listener = new PsrResponseListener(new HttpFoundationFactory()); + $event = $this->createEventMock([]); + + $listener->onKernelView($event); + self::assertFalse($event->hasResponse()); + + $event = $this->createEventMock(null); + + $listener->onKernelView($event); + self::assertFalse($event->hasResponse()); + } + + private function createEventMock($controllerResult): ViewEvent + { + return new ViewEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MASTER_REQUEST, $controllerResult); + } +} diff --git a/Tests/Fixtures/App/Controller/PsrRequestController.php b/Tests/Fixtures/App/Controller/PsrRequestController.php new file mode 100644 index 0000000..18b7741 --- /dev/null +++ b/Tests/Fixtures/App/Controller/PsrRequestController.php @@ -0,0 +1,45 @@ +responseFactory = $responseFactory; + $this->streamFactory = $streamFactory; + } + + public function serverRequestAction(ServerRequestInterface $request): ResponseInterface + { + return $this->responseFactory + ->createResponse() + ->withBody($this->streamFactory->createStream(sprintf('%s', $request->getMethod()))); + } + + public function requestAction(RequestInterface $request): ResponseInterface + { + return $this->responseFactory + ->createResponse() + ->withStatus(403) + ->withBody($this->streamFactory->createStream(sprintf('%s %s', $request->getMethod(), $request->getBody()->getContents()))); + } + + public function messageAction(MessageInterface $request): ResponseInterface + { + return $this->responseFactory + ->createResponse() + ->withStatus(422) + ->withBody($this->streamFactory->createStream(sprintf('%s', $request->getHeader('X-My-Header')[0]))); + } +} diff --git a/Tests/Fixtures/App/Kernel.php b/Tests/Fixtures/App/Kernel.php new file mode 100644 index 0000000..07367c9 --- /dev/null +++ b/Tests/Fixtures/App/Kernel.php @@ -0,0 +1,75 @@ +add('server_request', '/server-request')->controller([PsrRequestController::class, 'serverRequestAction'])->methods(['GET']) + ->add('request', '/request')->controller([PsrRequestController::class, 'requestAction'])->methods(['POST']) + ->add('message', '/message')->controller([PsrRequestController::class, 'messageAction'])->methods(['PUT']) + ; + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'router' => ['utf8' => true], + 'test' => true, + ]); + + $container->services() + ->set('nyholm.psr_factory', Psr17Factory::class) + ->alias(ResponseFactoryInterface::class, 'nyholm.psr_factory') + ->alias(ServerRequestFactoryInterface::class, 'nyholm.psr_factory') + ->alias(StreamFactoryInterface::class, 'nyholm.psr_factory') + ->alias(UploadedFileFactoryInterface::class, 'nyholm.psr_factory') + ; + + $container->services() + ->defaults()->autowire()->autoconfigure() + ->set(HttpFoundationFactoryInterface::class, HttpFoundationFactory::class) + ->set(HttpMessageFactoryInterface::class, PsrHttpFactory::class) + ->set(PsrResponseListener::class) + ->set(PsrServerRequestResolver::class) + ; + + $container->services() + ->set('logger', NullLogger::class) + ->set(PsrRequestController::class)->public()->autowire() + ; + } +} diff --git a/Tests/Fixtures/App/Kernel44.php b/Tests/Fixtures/App/Kernel44.php new file mode 100644 index 0000000..cdb3c1a --- /dev/null +++ b/Tests/Fixtures/App/Kernel44.php @@ -0,0 +1,66 @@ +add('/server-request', PsrRequestController::class.'::serverRequestAction')->setMethods(['GET']); + $routes->add('/request', PsrRequestController::class.'::requestAction')->setMethods(['POST']); + $routes->add('/message', PsrRequestController::class.'::messageAction')->setMethods(['PUT']); + } + + protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void + { + $container->loadFromExtension('framework', [ + 'test' => true, + ]); + + $container->register('nyholm.psr_factory', Psr17Factory::class); + $container->setAlias(ResponseFactoryInterface::class, 'nyholm.psr_factory'); + $container->setAlias(ServerRequestFactoryInterface::class, 'nyholm.psr_factory'); + $container->setAlias(StreamFactoryInterface::class, 'nyholm.psr_factory'); + $container->setAlias(UploadedFileFactoryInterface::class, 'nyholm.psr_factory'); + + $container->register(HttpFoundationFactoryInterface::class, HttpFoundationFactory::class)->setAutowired(true)->setAutoconfigured(true); + $container->register(HttpMessageFactoryInterface::class, PsrHttpFactory::class)->setAutowired(true)->setAutoconfigured(true); + $container->register(PsrResponseListener::class)->setAutowired(true)->setAutoconfigured(true); + $container->register(PsrServerRequestResolver::class)->setAutowired(true)->setAutoconfigured(true); + + $container->register('logger', NullLogger::class); + $container->register(PsrRequestController::class)->setPublic(true)->setAutowired(true); + } +} diff --git a/Tests/Functional/ControllerTest.php b/Tests/Functional/ControllerTest.php new file mode 100644 index 0000000..1ab5419 --- /dev/null +++ b/Tests/Functional/ControllerTest.php @@ -0,0 +1,46 @@ + + */ +final class ControllerTest extends WebTestCase +{ + public function testServerRequestAction() + { + $client = self::createClient(); + $crawler = $client->request('GET', '/server-request'); + + self::assertResponseStatusCodeSame(200); + self::assertSame('GET', $crawler->text()); + } + + public function testRequestAction() + { + $client = self::createClient(); + $crawler = $client->request('POST', '/request', [], [], [], 'some content'); + + self::assertResponseStatusCodeSame(403); + self::assertSame('POST some content', $crawler->text()); + } + + public function testMessageAction() + { + $client = self::createClient(); + $crawler = $client->request('PUT', '/message', [], [], ['HTTP_X_MY_HEADER' => 'some content']); + + self::assertResponseStatusCodeSame(422); + self::assertSame('some content', $crawler->text()); + } + + protected static function getKernelClass(): string + { + return SymfonyKernel::VERSION_ID >= 50200 ? Kernel::class : Kernel44::class; + } +} diff --git a/composer.json b/composer.json index bf201cd..02887b3 100644 --- a/composer.json +++ b/composer.json @@ -17,12 +17,19 @@ ], "require": { "php": ">=7.1", + "psr/http-factory": "^1.0", "psr/http-message": "^1.0", "symfony/http-foundation": "^4.4 || ^5.0" }, "require-dev": { + "symfony/browser-kit": "^4.4 || ^5.0", + "symfony/config": "^4.4 || ^5.0", + "symfony/event-dispatcher": "^4.4 || ^5.0", + "symfony/framework-bundle": "^4.4 || ^5.0", + "symfony/http-kernel": "^4.4 || ^5.0", "symfony/phpunit-bridge": "^4.4 || ^5.0", - "nyholm/psr7": "^1.1" + "nyholm/psr7": "^1.1", + "psr/log": "^1.1" }, "suggest": { "nyholm/psr7": "For a super lightweight PSR-7/17 implementation" @@ -35,7 +42,7 @@ }, "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "2.1-dev" } } }