diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65fcfee..ed32dbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,12 +10,9 @@ jobs: max-parallel: 10 fail-fast: false matrix: - php: ['7.3', '7.4', '8.0', '8.1'] - sf_version: ['4.4.*', '5.0.*', '5.2.*', '5.4.*', '6.0.*'] + php: ['7.4', '8.0', '8.1'] + sf_version: ['5.2.*', '5.4.*', '6.0.*'] exclude: - - php: 7.3 - sf_version: 6.0.* - - php: 7.4 sf_version: 6.0.* @@ -48,7 +45,7 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@2.7.0 with: - php-version: 7.3 + php-version: 7.4 coverage: pcov - name: Checkout code @@ -59,5 +56,5 @@ jobs: - name: Run tests env: - PHP_VERSION: 7.3 + PHP_VERSION: 7.4 run: ./vendor/bin/phpunit -v --coverage-text diff --git a/.gitignore b/.gitignore index 1928cc9..1a2bb4b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /composer.phar /composer.lock .phpunit.result.cache +/var/ diff --git a/README.md b/README.md index 4f0351f..6ef38d5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -This package configures Symfony to run on AWS Lambda using [Bref](https://bref.sh/). +Run Symfony on AWS Lambda using the [Bref](https://bref.sh/) runtime. [![Build Status](https://github.com/brefphp/symfony-bridge/workflows/Tests/badge.svg)](https://github.com/brefphp/symfony-bridge/actions) [![Latest Version](https://img.shields.io/packagist/v/bref/symfony-bridge?style=flat-square)](https://packagist.org/packages/bref/symfony-bridge) @@ -95,3 +95,69 @@ class Kernel extends BrefKernel + } } ``` + +## Handling requests in a kept-alive process without FPM + +> Note: this is an advanced topic. Don't bother with this unless you know what you are doing. + +To handle HTTP requests via the Symfony Kernel, without using PHP-FPM, by keeping the process alive: + +```diff +# serverless.yml + +functions: + app: +- handler: public/index.php ++ handler: App\Kernel + layers: + # Switch from PHP-FPM to the "function" runtime: +- - ${bref:layer.php-80-fpm} ++ - ${bref:layer.php-80} + environment: + # The Symfony process will restart every 100 requests + BREF_LOOP_MAX: 100 +``` + +The `App\Kernel` will be retrieved via Symfony Runtime from `public/index.php`. If you don't have a `public/index.php`, read the next sections. + +## Class handlers + +To handle other events (e.g. [SQS messages with Symfony Messenger](https://github.com/brefphp/symfony-messenger)) via a class name: + +```diff +# serverless.yml + +functions: + sqsHandler: +- handler: bin/consumer.php ++ handler: App\Service\MyService + layers: + - ${bref:layer.php-80} +``` + +The service will be retrieved via Symfony Runtime from the Symfony Kernel returned by `public/index.php`. + +> Note: the service must be configured as **public** (`public: true`) in the Symfony configuration. + +### Custom bootstrap file + +If you do not have a `public/index.php` file, you can create a file that returns the kernel (or any PSR-11 container): + +```php +=7.3", - "bref/bref": "^1.0", - "symfony/filesystem": "^4.4|^5.0|^6.0", - "symfony/http-kernel": "^4.4|^5.0|^6.0" + "php": ">=7.4", + "bref/bref": "^1.2", + "symfony/filesystem": "^5.2|^6.0", + "symfony/http-kernel": "^5.2|^6.0", + "symfony/psr-http-message-bridge": "^2.1", + "symfony/runtime": "^5.2|^6.0" }, "require-dev": { "mnapoli/hard-mode": "^0.3.0", - "phpunit/phpunit": "^8.0", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0" + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.22", + "symfony/config": "^5.2|^6.0", + "symfony/dependency-injection": "^5.2|^6.0", + "symfony/framework-bundle": "^5.2|^6.0", + "symfony/process": "^5.2|^6.0" }, "config": { "sort-packages": true, diff --git a/src/HandlerResolver.php b/src/HandlerResolver.php new file mode 100644 index 0000000..f9363e8 --- /dev/null +++ b/src/HandlerResolver.php @@ -0,0 +1,143 @@ +fileLocator = new FileHandlerLocator; + $this->symfonyContainer = null; + } + + /** + * {@inheritDoc} + */ + public function get($id) + { + $isComposed = strpos($id, ':') !== false; + + // By default we check if the handler is a file name (classic Bref behavior) + if (! $isComposed && $this->fileLocator->has($id)) { + return $this->fileLocator->get($id); + } + + $service = $id; + + $bootstrapFile = null; + if ($isComposed) { + [$bootstrapFile, $service] = explode(':', $id, 2); + } + + // If not, we try to get the handler from the Symfony container + $handler = $this->symfonyContainer($bootstrapFile)->get($service); + + // If the kernel was configured as a handler, then we wrap it to make it a valid HTTP handler for Lambda + if ($handler instanceof HttpKernelInterface) { + $handler = new KernelAdapter($handler); + } + + return $handler; + } + + /** + * {@inheritDoc} + */ + public function has($id): bool + { + $isComposed = strpos($id, ':') !== false; + + // By default we check if the handler is a file name (classic Bref behavior) + if (! $isComposed && $this->fileLocator->has($id)) { + return true; + } + + $service = $id; + + $bootstrapFile = null; + if ($isComposed) { + [$bootstrapFile, $service] = explode(':', $id, 2); + } + + // If not, we try to get the handler from the Symfony container + return $this->symfonyContainer($bootstrapFile)->has($service); + } + + /** + * Create and return the Symfony container. + */ + private function symfonyContainer(?string $bootstrapFile = null): ContainerInterface + { + // Only create it once + if (! $this->symfonyContainer) { + $bootstrapFile = $bootstrapFile ?: 'public/index.php'; + + if (! file_exists($bootstrapFile)) { + throw new Exception( + "Cannot find file '$bootstrapFile': the Bref-Symfony bridge tried to require that file to get the Symfony kernel. If your application does not have that file, follow the Bref-Symfony documentation to create and configure a file that returns the Symfony Kernel." + ); + } + + $app = require $bootstrapFile; + + if (! is_object($app)) { + throw new Exception(sprintf( + "The '%s' file must return an anonymous function (that returns the Symfony Kernel). Instead it returned '%s'. Either edit the file to return an anonymous function, or create a separate file (follow the online documentation to do so).", + $bootstrapFile, + // @phpstan-ignore-next-line + is_object($app) ? get_class($app) : gettype($app), + )); + } + + $projectDir = getenv('LAMBDA_TASK_ROOT') ?: null; + + // Use the Symfony Runtime component to resolve the closure and get the PSR-11 container + $options = $_SERVER['APP_RUNTIME_OPTIONS'] ?? []; + if ($projectDir) { + $options['project_dir'] = $projectDir; + } + $runtime = new BrefRuntime($options); + + [$app, $args] = $runtime + ->getResolver($app) + ->resolve(); + + $container = $app(...$args); + + if ($container instanceof KernelInterface) { + $container->boot(); + $container = $container->getContainer(); + } + + if (! $container instanceof ContainerInterface) { + throw new Exception(sprintf( + "The closure returned by '%s' must return either a Symfony Kernel or a PSR-11 container. Instead it returned '%s'", + $bootstrapFile, + is_object($container) ? get_class($container) : gettype($container), + )); + } + + $this->symfonyContainer = $container; + } + + return $this->symfonyContainer; + } +} diff --git a/src/Http/KernelAdapter.php b/src/Http/KernelAdapter.php new file mode 100644 index 0000000..941c4f2 --- /dev/null +++ b/src/Http/KernelAdapter.php @@ -0,0 +1,49 @@ +kernel = $kernel; + $this->symfonyFactory = new HttpFoundationFactory; + $psr17Factory = new Psr17Factory; + $this->psrFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + // From PSR-7 to Symfony + $symfonyRequest = $this->symfonyFactory->createRequest($request); + + $symfonyResponse = $this->kernel->handle($symfonyRequest); + + if ($this->kernel instanceof TerminableInterface) { + $this->kernel->terminate($symfonyRequest, $symfonyResponse); + } + + // From Symfony to PSR-7 + return $this->psrFactory->createResponse($symfonyResponse); + } +} diff --git a/src/Runtime/BrefRuntime.php b/src/Runtime/BrefRuntime.php new file mode 100644 index 0000000..4ffddc8 --- /dev/null +++ b/src/Runtime/BrefRuntime.php @@ -0,0 +1,9 @@ + new HandlerResolver); +} diff --git a/tests/Fixtures/MyService.php b/tests/Fixtures/MyService.php new file mode 100644 index 0000000..7d58195 --- /dev/null +++ b/tests/Fixtures/MyService.php @@ -0,0 +1,7 @@ +setPublic(true); + $c->setDefinition(MyService::class, $definition); + } else { + $c->services()->set(MyService::class)->public(); + } + } + + /** + * This method is needed only for supporting lower Symfony versions + * + * @param mixed $routes + */ + protected function configureRoutes($routes): void + { + } +} diff --git a/tests/TestKernel.php b/tests/TestKernel.php deleted file mode 100644 index 22279d6..0000000 --- a/tests/TestKernel.php +++ /dev/null @@ -1,23 +0,0 @@ -assertFalse($kernel->isLambda()); + self::assertFalse($kernel->isLambda()); putenv('LAMBDA_TASK_ROOT=/var/task'); - $this->assertTrue($kernel->isLambda()); + self::assertTrue($kernel->isLambda()); } } diff --git a/tests/Unit/HandlerResolverTest.php b/tests/Unit/HandlerResolverTest.php new file mode 100644 index 0000000..79520d4 --- /dev/null +++ b/tests/Unit/HandlerResolverTest.php @@ -0,0 +1,57 @@ +cwd = getcwd(); + chdir(__DIR__); + } + + public function tearDown(): void + { + chdir($this->cwd); + } + + public function test Bref uses our handler resolver() + { + self::assertInstanceOf(HandlerResolver::class, Bref::getContainer()); + } + + public function test files are resolved() + { + $resolver = new HandlerResolver; + self::assertTrue($resolver->has('fake-handler.php')); + $fileHandler = $resolver->get('fake-handler.php'); + self::assertInstanceOf(Closure::class, $fileHandler); + self::assertEquals('hello world', $fileHandler()); + } + + public function test Symfony services can be used as Lambda handlers() + { + $resolver = new HandlerResolver; + self::assertInstanceOf(MyService::class, $resolver->get(MyService::class)); + self::assertTrue($resolver->has(MyService::class)); + } + + public function test the Symfony kernel can be used as a HTTP handler() + { + $resolver = new HandlerResolver; + $handler = $resolver->get(TestKernel::class); + self::assertInstanceOf(RequestHandlerInterface::class, $handler); + self::assertInstanceOf(KernelAdapter::class, $handler); + } +} diff --git a/tests/Unit/fake-handler.php b/tests/Unit/fake-handler.php new file mode 100644 index 0000000..1bed93a --- /dev/null +++ b/tests/Unit/fake-handler.php @@ -0,0 +1,5 @@ +