diff --git a/docs/api/app.md b/docs/api/app.md index 30acaf6..709c9b7 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -218,3 +218,55 @@ leaking too much internal information. If you want to implement custom error handling, you're recommended to either catch any exceptions your own or use a custom [middleware handler](middleware.md) to catch any exceptions in your application. + +## Access log + +If you're using X with its [built-in web server](../best-practices/deployment.md#built-in-web-server), +it will log all requests and responses to console output (`STDOUT`) by default. + +```bash +$ php public/index.php +2023-07-21 17:30:03.617 Listening on http://0.0.0.0:8080 +2023-07-21 17:30:03.725 127.0.0.1 "GET / HTTP/1.1" 200 13 0.000 +2023-07-21 17:30:03.742 127.0.0.1 "GET /unknown HTTP/1.1" 404 956 0.000 +``` + +> ℹ️ **Framework X runs anywhere** +> +> This example uses the efficient built-in web server written in pure PHP. +> We also support running behind traditional web server setups like Apache, +> nginx, and more. If you're using X behind a traditional web server, X will not +> write an access log itself, but your web server of choice can be configured to +> write an access log instead. +> See [production deployment](../best-practices/deployment.md) for more details. + +Internally, the `App` will automatically add a default access log handler by +adding the [`AccessLogHandler`](middleware.md#accessloghandler) to the list of +middleware used. You may also explicitly pass an [`AccessLogHandler`](middleware.md#accessloghandler) +middleware to the `App` like this: + +```php title="public/index.php" +run(); +``` + +> ⚠️ **Feature preview** +> +> Note that the [`AccessLogHandler`](middleware.md#accessloghandler) may +> currently only be passed as a global middleware instance and not as a global +> middleware name to the `App` and may not be used for individual routes. + +If you pass an [`AccessLogHandler`](middleware.md#accessloghandler) to the `App`, +it must be followed by an [`ErrorHandler`](middleware.md#errorhandler) like in +the previous example. See also [error handling](#error-handling) for more +details. diff --git a/docs/api/middleware.md b/docs/api/middleware.md index 58978a4..5aede60 100644 --- a/docs/api/middleware.md +++ b/docs/api/middleware.md @@ -556,6 +556,19 @@ Global middleware handlers will always be called before route middleware handler ## Built-in middleware +### AccessLogHandler + +> ⚠️ **Feature preview** +> +> This is a feature preview, i.e. it might not have made it into the current beta. +> Give feedback to help us prioritize. +> We also welcome [contributors](../getting-started/community.md) to help out! + +X ships with a built-in `AccessLogHandler` middleware that is responsible for +logging any requests and responses from following middleware and controllers. +This default access log handling can be configured through the [`App`](app.md). +See [access logging](app.md#access-logging) for more details. + ### ErrorHandler > ⚠️ **Feature preview** diff --git a/src/AccessLogHandler.php b/src/AccessLogHandler.php index 5c0a3f5..4b26259 100644 --- a/src/AccessLogHandler.php +++ b/src/AccessLogHandler.php @@ -9,7 +9,7 @@ use React\Stream\ReadableStreamInterface; /** - * @internal + * @final */ class AccessLogHandler { diff --git a/src/App.php b/src/App.php index 3551f7f..3283a50 100644 --- a/src/App.php +++ b/src/App.php @@ -41,28 +41,44 @@ public function __construct(...$middleware) // new MiddlewareHandler([$fiberHandler, $accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) $handlers = []; + // only log for built-in webserver and PHP development webserver by default, others have their own access log + $needsAccessLog = (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server'); + $container = new Container(); if ($middleware) { + $needsErrorHandlerNext = false; foreach ($middleware as $handler) { + if ($needsErrorHandlerNext && !$handler instanceof ErrorHandler) { + break; + } + $needsErrorHandlerNext = false; + if ($handler instanceof Container) { $container = $handler; - } elseif ($handler === ErrorHandler::class) { - throw new \TypeError('ErrorHandler may currently only be passed as instance'); + } elseif ($handler === ErrorHandler::class || $handler === AccessLogHandler::class) { + throw new \TypeError($handler . ' may currently only be passed as a middleware instance'); } elseif (!\is_callable($handler)) { $handlers[] = $container->callable($handler); } else { $handlers[] = $handler; + if ($handler instanceof AccessLogHandler) { + $needsAccessLog = false; + $needsErrorHandlerNext = true; + } } } + if ($needsErrorHandlerNext) { + throw new \TypeError('AccessLogHandler must be followed by ErrorHandler'); + } } // add default ErrorHandler as first handler unless it is already added explicitly - if (!($handlers[0] ?? null) instanceof ErrorHandler) { + if (!($handlers[0] ?? null) instanceof ErrorHandler && !($handlers[0] ?? null) instanceof AccessLogHandler) { \array_unshift($handlers, new ErrorHandler()); } // only log for built-in webserver and PHP development webserver by default, others have their own access log - if (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') { + if ($needsAccessLog) { \array_unshift($handlers, new AccessLogHandler()); } diff --git a/src/RouteHandler.php b/src/RouteHandler.php index 5bfc021..94f5486 100644 --- a/src/RouteHandler.php +++ b/src/RouteHandler.php @@ -55,6 +55,8 @@ public function map(array $methods, string $route, $handler, ...$handlers): void if ($handler instanceof Container && $i !== $last) { $container = $handler; unset($handlers[$i]); + } elseif ($handler instanceof AccessLogHandler || $handler === AccessLogHandler::class) { + throw new \TypeError('AccessLogHandler may currently only be passed as a global middleware instance'); } elseif (!\is_callable($handler)) { $handlers[$i] = $container->callable($handler); } diff --git a/tests/AppTest.php b/tests/AppTest.php index 59754a8..03393df 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -181,7 +181,7 @@ public function testConstructWithErrorHandlerOnlyAssignsErrorHandlerAfterDefault public function testConstructWithErrorHandlerClassThrows() { $this->expectException(\TypeError::class); - $this->expectExceptionMessage('ErrorHandler may currently only be passed as instance'); + $this->expectExceptionMessage('ErrorHandler may currently only be passed as a middleware instance'); new App(ErrorHandler::class); } @@ -241,6 +241,88 @@ public function testConstructWithMiddlewareAndErrorHandlerAssignsGivenErrorHandl $this->assertInstanceOf(RouteHandler::class, $handlers[4]); } + public function testConstructWithAccessLogHandlerAndErrorHandlerAssignsHandlersAsGiven() + { + $accessLogHandler = new AccessLogHandler(); + $errorHandler = new ErrorHandler(); + + $app = new App($accessLogHandler, $errorHandler); + + $ref = new ReflectionProperty($app, 'handler'); + $ref->setAccessible(true); + $handler = $ref->getValue($app); + + $this->assertInstanceOf(MiddlewareHandler::class, $handler); + $ref = new ReflectionProperty($handler, 'handlers'); + $ref->setAccessible(true); + $handlers = $ref->getValue($handler); + + if (PHP_VERSION_ID >= 80100) { + $first = array_shift($handlers); + $this->assertInstanceOf(FiberHandler::class, $first); + } + + $this->assertCount(3, $handlers); + $this->assertSame($accessLogHandler, $handlers[0]); + $this->assertSame($errorHandler, $handlers[1]); + $this->assertInstanceOf(RouteHandler::class, $handlers[2]); + } + + public function testConstructWithMiddlewareBeforeAccessLogHandlerAndErrorHandlerAssignsDefaultErrorHandlerAsFirstHandlerFollowedByGivenHandlers() + { + $middleware = static function (ServerRequestInterface $request, callable $next) { }; + $accessLog = new AccessLogHandler(); + $errorHandler = new ErrorHandler(); + + $app = new App($middleware, $accessLog, $errorHandler); + + $ref = new ReflectionProperty($app, 'handler'); + $ref->setAccessible(true); + $handler = $ref->getValue($app); + + $this->assertInstanceOf(MiddlewareHandler::class, $handler); + $ref = new ReflectionProperty($handler, 'handlers'); + $ref->setAccessible(true); + $handlers = $ref->getValue($handler); + + if (PHP_VERSION_ID >= 80100) { + $first = array_shift($handlers); + $this->assertInstanceOf(FiberHandler::class, $first); + } + + $this->assertCount(5, $handlers); + $this->assertInstanceOf(ErrorHandler::class, $handlers[0]); + $this->assertNotSame($errorHandler, $handlers[0]); + $this->assertSame($middleware, $handlers[1]); + $this->assertSame($accessLog, $handlers[2]); + $this->assertSame($errorHandler, $handlers[3]); + $this->assertInstanceOf(RouteHandler::class, $handlers[4]); + } + + public function testConstructWithAccessLogHandlerOnlyThrows() + { + $accessLogHandler = new AccessLogHandler(); + + $this->expectException(\TypeError::class); + new App($accessLogHandler); + } + + public function testConstructWithAccessLogHandlerClassThrows() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('AccessLogHandler may currently only be passed as a middleware instance'); + new App(AccessLogHandler::class); + } + + public function testConstructWithAccessLogHandlerFollowedByMiddlewareThrows() + { + $accessLogHandler = new AccessLogHandler(); + $middleware = function (ServerRequestInterface $request, callable $next) { }; + + $this->expectException(\TypeError::class); + new App($accessLogHandler, $middleware); + } + public function testRunWillReportListeningAddressAndRunLoopWithSocketServer() { $socket = @stream_socket_server('127.0.0.1:8080'); @@ -572,6 +654,22 @@ public function testMapMethodAddsRouteOnRouter() $app->map(['GET', 'POST'], '/', function () { }); } + public function testGetWithAccessLogHandlerAsMiddlewareThrows() + { + $app = new App(); + + $this->expectException(\TypeError::class); + $app->get('/', new AccessLogHandler(), function () { }); + } + + public function testGetWithAccessLogHandlerClassAsMiddlewareThrows() + { + $app = new App(); + + $this->expectException(\TypeError::class); + $app->get('/', AccessLogHandler::class, function () { }); + } + public function testRedirectMethodAddsAnyRouteOnRouterWhichWhenInvokedReturnsRedirectResponseWithTargetLocation() { $app = new App();