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]);
+ }
+}