diff --git a/composer.json b/composer.json index 88ff7d9..ecb97fa 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "psr/http-message": "^2.0", "psr/http-factory": "^1.0", "psr/http-server-handler": "^1.0", + "psr/log": "^3.0", "guzzlehttp/psr7": "^2.0", "nikic/fast-route": "^1.3" }, diff --git a/composer.lock b/composer.lock index b631b9e..51b224c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bfa3f4a827ed1cc39aa7fc27ac40d13a", + "content-hash": "0c0bda01f8c04d2e20e328eeb6eff878", "packages": [ { "name": "guzzlehttp/psr7", @@ -336,6 +336,56 @@ }, "time": "2023-04-10T20:06:20+00:00" }, + { + "name": "psr/log", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "79dff0b268932c640297f5208d6298f71855c03e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", + "reference": "79dff0b268932c640297f5208d6298f71855c03e", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.1" + }, + "time": "2024-08-21T13:31:24+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", diff --git a/examples/logger/README.md b/examples/logger/README.md new file mode 100644 index 0000000..46f0184 --- /dev/null +++ b/examples/logger/README.md @@ -0,0 +1,23 @@ +# Logger Example + +This example demonstrates how to use a logger in Dumbo. + +## Running the Example + +1. Install dependencies: + + ```bash + composer install + ``` + +2. Start the server: + + ```bash + composer start + ``` + +3. Access the route: + + ```bash + curl -v http://localhost:8000 + ``` diff --git a/examples/logger/composer.json b/examples/logger/composer.json new file mode 100644 index 0000000..7630c30 --- /dev/null +++ b/examples/logger/composer.json @@ -0,0 +1,15 @@ +{ + "require": { + "notrab/dumbo": "@dev", + "monolog/monolog": "^3.7" + }, + "repositories": [ + { + "type": "path", + "url": "../../" + } + ], + "scripts": { + "start": "php -S localhost:8000 index.php" + } +} diff --git a/examples/logger/index.php b/examples/logger/index.php new file mode 100644 index 0000000..0132a3b --- /dev/null +++ b/examples/logger/index.php @@ -0,0 +1,58 @@ +setFormatter($formatter); +$logger->pushHandler($handler); + +$app->use(Logger::logger($logger)); + +$app->get("/", function ($context) { + return $context->html("

We've just logged something on the console!

"); +}); + +$userData = [ + [ + "id" => 1, + "name" => "Jamie Barton", + "email" => "jamie@notrab.dev", + ], +]; + +$user = new Dumbo(); + +$user->get("/", function ($c) use ($userData) { + return $c->json($userData); +}); + +$user->get("/:id", function ($c) use ($userData) { + $id = (int) $c->req->param("id"); + + $user = + array_values(array_filter($userData, fn($u) => $u["id"] === $id))[0] ?? + null; + + if (!$user) { + return $c->json(["error" => "User not found"], 404); + } + + return $c->json($user); +}); + +$app->route("/users", $user); + +$app->run(); diff --git a/src/Dumbo.php b/src/Dumbo.php index c9d45f8..f5cb9ff 100644 --- a/src/Dumbo.php +++ b/src/Dumbo.php @@ -130,30 +130,36 @@ class Dumbo try { $route = $this->router->findRoute($request); + $context = new Context( + $request, + $route ? $route["params"] : [], + $route ? $route["routePath"] : "" + ); + + $fullMiddlewareStack = $this->getFullMiddlewareStack(); + if ($route) { - $context = new Context( - $request, - $route["params"], - $route["routePath"] - ); - - $fullMiddlewareStack = array_merge( - $this->getFullMiddlewareStack(), + $fullMiddlewareStack = array_unique(array_merge( + $fullMiddlewareStack, $route["middleware"] ?? [] - ); + ), SORT_REGULAR); - $response = $this->runMiddleware( - $context, - $route["handler"], - $fullMiddlewareStack - ); - - return $response instanceof ResponseInterface - ? $response - : $context->getResponse(); + $handler = $route["handler"]; + } else { + $handler = function () { + return new Response(404, [], "404 Not Found"); + }; } - return new Response(404, [], "404 Not Found"); + $response = $this->runMiddleware( + $context, + $handler, + $fullMiddlewareStack + ); + + return $response instanceof ResponseInterface + ? $response + : $context->getResponse(); } catch (HTTPException $e) { return $this->handleHTTPException($e, $request); } catch (\Exception $e) { @@ -306,7 +312,6 @@ class Dumbo private function getFullMiddlewareStack(): array { $stack = $this->middleware; - $current = $this; while ($current->parent !== null) { diff --git a/src/Helpers/Logger.php b/src/Helpers/Logger.php new file mode 100644 index 0000000..756e87e --- /dev/null +++ b/src/Helpers/Logger.php @@ -0,0 +1,113 @@ +'; + public const LOG_PREFIX_OUTGOING = '<--'; + + private LoggerInterface $logger; + + /** + * Constructor to initialize the logger with a PSR-3 compliant logger. + * + * @param LoggerInterface $logger The PSR-3 compliant logger. + */ + private function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * Create a middleware that logs incoming requests. + * + * @param LoggerInterface $logger The PSR-3 compliant logger. + * @return callable The middleware. + */ + public static function logger(LoggerInterface $logger): callable + { + $middleware = new self($logger); + return $middleware; + } + + /** + * Invoke the logger middleware. + * + * @param Context $ctx The context object containing the request. + * @param callable $next The next middleware or handler. + * @return ResponseInterface The HTTP response. + */ + public function __invoke(Context $ctx, callable $next): ResponseInterface + { + $method = $ctx->req->method(); + $path = $ctx->req->path(); + + $this->log(self::LOG_PREFIX_INCOMING, $method, $path); + + $start = microtime(true); + $response = $next($ctx); + $elapsed = $this->getElapsedTime($start); + + $this->log(self::LOG_PREFIX_OUTGOING, $method, $path, $response->getStatusCode(), $elapsed); + + return $response; + } + + /** + * Log a message with the specified prefix, method, path, status, and elapsed time. + * + * @param string $prefix The log prefix indicating incoming or outgoing. + * @param string $method The HTTP method of the request. + * @param string $path The request path. + * @param int $status The HTTP status code (default is 0). + * @param string $elapsed The elapsed time for processing the request (default is an empty string). + * @return void + */ + private function log(string $prefix, string $method, string $path, int $status = 0, string $elapsed = ''): void + { + $message = $prefix === self::LOG_PREFIX_INCOMING + ? sprintf("%s %s %s", $prefix, $method, $path) + : sprintf("%s %s %s %s %s", $prefix, $method, $path, $this->colorStatus($status), $elapsed); + + $this->logger->info($message); + } + + /** + * Calculate the elapsed time since the given start time. + * + * @param float $start The start time in microseconds. + * @return string The elapsed time in milliseconds or seconds. + */ + private function getElapsedTime(float $start): string + { + $delta = (microtime(true) - $start) * 1000; + return $delta < 1000 ? $delta . 'ms' : round($delta / 1000, 3) . 's'; + } + + /** + * Get a colored string representation of the HTTP status code. + * + * @param int $status The HTTP status code. + * @return string The colored status code. + */ + private function colorStatus(int $status): string + { + $colors = [ + 7 => "\033[35m%d\033[0m", + 5 => "\033[31m%d\033[0m", + 4 => "\033[33m%d\033[0m", + 3 => "\033[36m%d\033[0m", + 2 => "\033[32m%d\033[0m", + 1 => "\033[32m%d\033[0m", + 0 => "\033[33m%d\033[0m", + ]; + + $colorCode = $colors[intdiv($status, 100)] ?? "%d"; + return sprintf($colorCode, $status); + } +} diff --git a/tests/Helpers/LoggerTest.php b/tests/Helpers/LoggerTest.php new file mode 100644 index 0000000..a07771f --- /dev/null +++ b/tests/Helpers/LoggerTest.php @@ -0,0 +1,66 @@ +createMock(UriInterface::class); + $uri->method('getPath')->willReturn($path); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getUri')->willReturn($uri); + $request->method('getMethod')->willReturn($method); + + return new Context($request, [], ""); + } + + public function testLoggerInstance() + { + $loggerMock = $this->createMock(LoggerInterface::class); + $middleware = Logger::logger($loggerMock); + + $this->assertInstanceOf(Logger::class, $middleware); + } + + public function testInvokeLogsIncomingAndOutgoingRequest() + { + $loggerMock = $this->createMock(LoggerInterface::class); + + $messages = []; + $loggerMock->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function ($message) use (&$messages) { + $messages[] = $message; + }); + + $context = $this->createMockContext('GET', '/test-path'); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getStatusCode')->willReturn(200); + + $middleware = Logger::logger($loggerMock); + + $next = fn () => $responseMock; + + $middleware($context, $next); + + $this->assertCount(2, $messages); + + $this->assertStringContainsString(Logger::LOG_PREFIX_INCOMING, $messages[0]); + $this->assertStringContainsString('GET /test-path', $messages[0]); + + $this->assertStringContainsString(Logger::LOG_PREFIX_OUTGOING, $messages[1]); + $this->assertStringContainsString('GET /test-path', $messages[1]); + $this->assertStringContainsString('200', $messages[1]); + } +}