From 6e0aa7bf343aaccdfe87e665158d800e327495f4 Mon Sep 17 00:00:00 2001 From: Sander De la Marche Date: Sun, 26 Nov 2023 21:07:55 +0100 Subject: [PATCH 1/2] Switch to a PSR-7 implementation --- README.md | 32 +++++++++++++++----------------- composer.json | 5 ++++- src/Mono.php | 46 +++++++++++++++++++++++++++++++--------------- tests/MonoTest.php | 38 ++++++++++++++++++++++++++++++++++---- 4 files changed, 84 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f4f7bfb..83a2764 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,30 @@ Mono is a tiny, single-class PHP framework for writing single-page PHP apps. It shines when quickly developing small tools with limited scope. -In +- 70 LOC, you get basic routing (using FastRoute), DI (using PHP-DI), -and Twig templating. Anything else you need, you have to bring yourself. +In +- 100 LOC, you get basic routing (using FastRoute), DI (using PHP-DI), +a PSR-7 implementation and Twig templating. ## Routing Mono's routing implementation shares 90% of its code with the ['basic usage example'](https://github.com/nikic/FastRoute#usage) from the FastRoute documentation. -You use `addRoute()` to add all your routes. Same method signature as the FastRoute method. Route handlers are closures by default, since this is a single-page framework. +You use `$mono->addRoute()` to add all your routes. Same method signature as the FastRoute method. Route handlers are closures by default, since this is a single-page framework. Read about the route pattern in the [FastRoute documentation](https://github.com/nikic/FastRoute#defining-routes). The entered path is passed directly to FastRoute. -When `$mono->run()` is called, the current request is matched against the routes you added, the closure is invoked and the string result is echo'd. +The first argument to the closure is the always current request, which is a [PSR-7 ServerRequestInterface](https://github.com/php-fig/http-message/blob/master/src/ServerRequestInterface.php) object. After that, the next arguments are the route parameters. + +When `$mono->run()` is called, the current request is matched against the routes you added, the closure is invoked and the response is emitted. ```php -use App\Mono;$mono = new Mono(); +$mono = new Mono(); -$mono->addRoute('GET', '/', function() use ($mono) { - return 'Hello world!'; +$mono->addRoute('GET', '/books/{book}', function(RequestInterface $request, string $book) use ($mono) { + return $mono->createResponse('Book: ' . $book); }); -echo $mono->run(); +$mono->run(); ``` -Mono does not implement PSR-7 because it would be overkill for most tools you'd build in a single page . - -If you do need status codes and headers, you can use `http_response_code()` and `header()` to do so. - ## DI When a Mono object is created, it constructs a basic PHP-DI container with default configuration. This means dependencies from your vendor folder are autowired. @@ -35,15 +33,15 @@ When a Mono object is created, it constructs a basic PHP-DI container with defau You can fetch instances from the container with the `get()` method on your Mono object. ```php -use App\Mono;$mono = new Mono(); +$mono = new Mono(); $mono->addRoute('GET', '/example', function() use ($mono) { $result = $mono->get(SomeDependency::class)->doSomething(); - return json_encode($result); + return $mono->createResponse(json_encode($result)); }); -echo $mono->run(); +$mono->run(); ``` ## Twig @@ -51,7 +49,7 @@ echo $mono->run(); Mono comes with Twig out-of-the-box. You can use the `render()` method on your Mono object to render a Twig template. ```php -use App\Mono;$mono = new Mono(); +$mono = new Mono(); $mono->addRoute('GET', '/example', function() use ($mono) { $result = $mono->get(SomeDependency::class)->doSomething(); @@ -61,7 +59,7 @@ $mono->addRoute('GET', '/example', function() use ($mono) { ]); }); -echo $mono->run(); +$mono->run(); ```` Templates go in the `/templates` folder in the project root. \ No newline at end of file diff --git a/composer.json b/composer.json index 77e3070..fba4ac9 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,10 @@ "php": "^8.2 || ^8.3", "nikic/fast-route": "^1.3", "twig/twig": "^3.7", - "php-di/php-di": "^7.0" + "php-di/php-di": "^7.0", + "nyholm/psr7-server": "^1.1", + "nyholm/psr7": "^1.8", + "laminas/laminas-httphandlerrunner": "^2.9" }, "require-dev": { "symfony/var-dumper": "^6.3", diff --git a/src/Mono.php b/src/Mono.php index 2011a84..1a5a039 100644 --- a/src/Mono.php +++ b/src/Mono.php @@ -6,7 +6,12 @@ use DI\Container; use FastRoute; +use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\Response; +use Nyholm\Psr7Server\ServerRequestCreator; use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; use Twig\Environment; use Twig\Extension\DebugExtension; use Twig\Loader\FilesystemLoader; @@ -51,11 +56,13 @@ public function addRoute(string $method, string $path, callable $handler): void /** * @param array $data */ - public function render(string $template, array $data = []): string + public function render(string $template, array $data = []): ResponseInterface { $template = $this->twig->load($template); - return $template->render($data); + $output = $template->render($data); + + return $this->createResponse($output); } public function get(string $className): mixed @@ -63,7 +70,14 @@ public function get(string $className): mixed return $this->container->get($className); } - public function run(): mixed + public function createResponse(string $body, int $status = 200): Response + { + $psr17Factory = new Psr17Factory(); + + return (new Response($status))->withBody($psr17Factory->createStream($body)); + } + + public function run(): void { $dispatcher = FastRoute\simpleDispatcher(function (FastRoute\RouteCollector $r) { foreach ($this->routes as $route) { @@ -71,21 +85,23 @@ public function run(): mixed } }); - $httpMethod = $_SERVER['REQUEST_METHOD']; - $uri = $_SERVER['REQUEST_URI']; + $psr17Factory = new Psr17Factory(); - if (false !== $pos = strpos($uri, '?')) { - $uri = substr($uri, 0, $pos); - } - $uri = rawurldecode($uri); + $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + $request = $creator->fromGlobals(); - $routeInfo = $dispatcher->dispatch($httpMethod, $uri); + $route = $dispatcher->dispatch($request->getMethod(), $request->getUri()->getPath()); - return match ($routeInfo[0]) { - FastRoute\Dispatcher::NOT_FOUND => '404 Not Found', - FastRoute\Dispatcher::METHOD_NOT_ALLOWED => '405 Method Not Allowed', - FastRoute\Dispatcher::FOUND => call_user_func_array($routeInfo[1], $routeInfo[2]), - default => throw new \RuntimeException('Something went wrong'), + /** @var ?ResponseInterface $response */ + $response = match ($route[0]) { + FastRoute\Dispatcher::NOT_FOUND => $this->createResponse('404 Not Found', 404), + FastRoute\Dispatcher::METHOD_NOT_ALLOWED => $this->createResponse('405 Method Not Allowed', 405), + FastRoute\Dispatcher::FOUND => call_user_func_array($route[1], [$request, ...$route[2]]), + default => $this->createResponse('500 Internal Server Error', 500) }; + + if ($response instanceof ResponseInterface) { + (new SapiEmitter())->emit($response); + } } } diff --git a/tests/MonoTest.php b/tests/MonoTest.php index 835d3ff..9b807d1 100644 --- a/tests/MonoTest.php +++ b/tests/MonoTest.php @@ -5,9 +5,23 @@ use Mono\Mono; use PhpParser\NodeTraverser; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; class MonoTest extends TestCase { + private function catchOutput(callable $run): string + { + ob_start(); + + $run(); + + $output = ob_get_contents(); + + ob_end_clean(); + + return !$output ? '' : $output; + } + public function testRouting(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -15,15 +29,31 @@ public function testRouting(): void $mono = new Mono(__DIR__); - $mono->addRoute('GET', '/', function () { - return 'Hello, world!'; + $mono->addRoute('GET', '/', function (RequestInterface $request) use ($mono) { + return $mono->createResponse('Hello, world!'); }); - $output = $mono->run(); + $output = $this->catchOutput(fn() => $mono->run()); $this->assertEquals('Hello, world!', $output); } + public function testRoutingWithParameters(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/books/123'; + + $mono = new Mono(__DIR__); + + $mono->addRoute('GET', '/books/{book}', function (RequestInterface $request, string $book) use ($mono) { + return $mono->createResponse('Book: ' . $book); + }); + + $output = $this->catchOutput(fn() => $mono->run()); + + $this->assertEquals('Book: 123', $output); + } + public function testTwigRendering(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -37,7 +67,7 @@ public function testTwigRendering(): void ]); }); - $output = $mono->run(); + $output = $this->catchOutput(fn() => $mono->run()); $this->assertEquals('Hello, world!', $output); } From b738050b1c61a371b66d2e1a8ebf7c62aa8045a3 Mon Sep 17 00:00:00 2001 From: Sander De la Marche Date: Sun, 26 Nov 2023 21:14:45 +0100 Subject: [PATCH 2/2] Make sure each closure returns a valid response --- src/Mono.php | 7 +++++-- tests/MonoTest.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Mono.php b/src/Mono.php index 1a5a039..8438deb 100644 --- a/src/Mono.php +++ b/src/Mono.php @@ -100,8 +100,11 @@ public function run(): void default => $this->createResponse('500 Internal Server Error', 500) }; - if ($response instanceof ResponseInterface) { - (new SapiEmitter())->emit($response); + if (!$response instanceof ResponseInterface) { + throw new \RuntimeException('Invalid response received from route ' . $request->getUri()->getPath() . + '. Please return a valid PSR-7 response from your handler.'); } + + (new SapiEmitter())->emit($response); } } diff --git a/tests/MonoTest.php b/tests/MonoTest.php index 9b807d1..0235e95 100644 --- a/tests/MonoTest.php +++ b/tests/MonoTest.php @@ -83,8 +83,26 @@ public function testDependencyInjection(): void $demoClass = $mono->get(NodeTraverser::class); $this->assertInstanceOf(NodeTraverser::class, $demoClass); + + return $mono->createResponse('OK'); }); $mono->run(); } + + public function testInvalidResponseThrowError(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/'; + + $mono = new Mono(__DIR__); + + $mono->addRoute('GET', '/', function () { + return 'OK'; + }); + + $this->expectException(\RuntimeException::class); + + $mono->run(); + } }