Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
102 / 102
98.90% covered (success)
98.90%
90 / 91
45.35% covered (danger)
45.35%
39 / 86
96.55% covered (success)
96.55%
28 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
App
100.00% covered (success)
100.00%
102 / 102
98.90% covered (success)
98.90%
90 / 91
45.35% covered (danger)
45.35%
39 / 86
100.00% covered (success)
100.00%
29 / 29
475.56
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 router
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 factory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 group
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 staticRoute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMiddleware
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 errorHandler
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 emitter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 middleware
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 before
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 after
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logger
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 container
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initializeContainer
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 teardown
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
0.00% covered (danger)
0.00%
0 / 3
100.00% covered (success)
100.00%
1 / 1
2
 run
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
9 / 9
0.00% covered (danger)
0.00%
0 / 6
100.00% covered (success)
100.00%
1 / 1
3
 serve
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
5 / 5
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 reusable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 respond
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 endRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 finish
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
13 / 13
13.64% covered (danger)
13.64%
3 / 22
100.00% covered (success)
100.00%
1 / 1
21.10
 report
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
12 / 12
33.33% covered (danger)
33.33%
4 / 12
100.00% covered (success)
100.00%
1 / 1
12.41
 recordServerException
100.00% covered (success)
100.00%
2 / 2
83.33% covered (warning)
83.33%
5 / 6
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
3.69
 emitFailure
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
6.00
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Core;
6
7use Celema\Container\Container;
8use Celema\Container\Entry;
9use Celema\Container\Exception\ResetFailed;
10use Celema\Core\Emitter\Emitter;
11use Celema\Core\Emitter\Fallback;
12use Celema\Core\Emitter\Sapi;
13use Celema\Core\Error\Handler as ErrorHandler;
14use Celema\Core\Error\Log;
15use Celema\Core\Factory\Factory;
16use Celema\Core\Factory\Nyholm;
17use Celema\Core\Runtime\FrankenPhpWorker;
18use Celema\Router\AddsRoutes;
19use Celema\Router\After;
20use Celema\Router\Before;
21use Celema\Router\Dispatcher;
22use Celema\Router\Route;
23use Celema\Router\RouteAdder;
24use Celema\Router\Router;
25use Celema\Router\RoutingHandler;
26use Celema\Server\Console as ServerConsole;
27use Closure;
28use Override;
29use Psr\Container\ContainerInterface as PsrContainer;
30use Psr\Http\Message\ResponseInterface as Response;
31use Psr\Http\Message\ServerRequestInterface as Request;
32use Psr\Http\Server\MiddlewareInterface as Middleware;
33use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
34use Psr\Log\LoggerInterface as Logger;
35use Psr\Log\LogLevel;
36use Throwable;
37
38/** @api */
39class App implements RouteAdder, RequestHandler
40{
41    use AddsRoutes;
42
43    protected readonly Dispatcher $dispatcher;
44    protected ?ErrorHandler $errorHandler = null;
45    protected Emitter $emitter;
46
47    /** @var list<Closure(): void> */
48    protected array $teardownHooks = [];
49
50    /**
51     * False once a request left state behind that the app cannot vouch for:
52     * a throwable escaped the request, or its teardown failed.
53     */
54    protected bool $reusable = true;
55
56    public function __construct(
57        protected readonly Factory $factory,
58        protected readonly Router $router,
59        protected readonly Container $container,
60    ) {
61        $this->dispatcher = new Dispatcher();
62        $this->emitter = new Sapi();
63        $this->initializeContainer();
64    }
65
66    public function load(Plugin $plugin): void
67    {
68        $plugin->load($this);
69    }
70
71    public static function create(?PsrContainer $container = null): self
72    {
73        return new self(
74            new Nyholm(),
75            new Router(),
76            new Container(container: $container),
77        );
78    }
79
80    public function router(): Router
81    {
82        return $this->router;
83    }
84
85    public function factory(): Factory
86    {
87        return $this->factory;
88    }
89
90    #[Override]
91    public function addRoute(Route $route): Route
92    {
93        return $this->router->addRoute($route);
94    }
95
96    #[Override]
97    public function group(
98        string $patternPrefix,
99        Closure $createClosure,
100        string $namePrefix = '',
101    ): void {
102        $this->router->group($patternPrefix, $createClosure, $namePrefix);
103    }
104
105    public function staticRoute(
106        string $prefix,
107        string $path,
108        string $name = '',
109    ): void {
110        $this->router->addStatic($prefix, $path, $name);
111    }
112
113    /** @return list<Middleware> */
114    public function getMiddleware(): array
115    {
116        return $this->dispatcher->getMiddleware();
117    }
118
119    public function errorHandler(?ErrorHandler $handler = null): ?ErrorHandler
120    {
121        if ($handler !== null) {
122            $this->errorHandler = $handler;
123        }
124
125        return $this->errorHandler;
126    }
127
128    public function emitter(?Emitter $emitter = null): Emitter
129    {
130        if ($emitter !== null) {
131            $this->emitter = $emitter;
132        }
133
134        return $this->emitter;
135    }
136
137    public function middleware(Middleware ...$middleware): void
138    {
139        $this->dispatcher->middleware(...$middleware);
140    }
141
142    public function before(Before $beforeHandler): static
143    {
144        $this->dispatcher->before($beforeHandler);
145
146        return $this;
147    }
148
149    public function after(After $afterHandler): static
150    {
151        $this->dispatcher->after($afterHandler);
152
153        return $this;
154    }
155
156    public function logger(Logger|callable $logger): void
157    {
158        if ($logger instanceof Logger) {
159            $this->container->add(Logger::class, $logger);
160        } else {
161            $this->container->add(Logger::class, Closure::fromCallable($logger));
162        }
163    }
164
165    public function container(): Container
166    {
167        return $this->container;
168    }
169
170    /**
171     * @param non-empty-string $key
172     * @param class-string|object $value
173     */
174    public function register(string $key, object|string $value): Entry
175    {
176        return $this->container->add($key, $value);
177    }
178
179    public function initializeContainer(): void
180    {
181        $this->container->add(Router::class, $this->router);
182        $this->container->add($this->router::class, $this->router);
183
184        $this->container->add(Factory::class, $this->factory);
185        $this->container->add($this->factory::class, $this->factory);
186    }
187
188    /**
189     * Registers a callback that runs after every request, once the request's
190     * container scope was reset: in run() after the response was emitted and,
191     * where the runtime allows, the request ended, so the client does not
192     * wait for it; in handle() before the response is returned. Use it to
193     * bring resources that outlive a request back to a clean state, such as
194     * rolling back a database transaction left open, or for work the client
195     * need not wait for.
196     *
197     * Callbacks run in registration order. A failing callback does not stop
198     * the others; failures are logged and the app is no longer reusable.
199     *
200     * @param Closure(): void $hook
201     */
202    public function teardown(Closure $hook): void
203    {
204        $this->teardownHooks[] = $hook;
205    }
206
207    /**
208     * Handles the request in its own container scope without emitting the
209     * response. The scope is reset and the teardown hooks have run when the
210     * response is returned, so its body must not depend on scoped services.
211     */
212    #[Override]
213    public function handle(Request $request): Response
214    {
215        $scope = $this->container->scope();
216
217        try {
218            return $this->respond($request, $scope);
219        } catch (Throwable $e) {
220            $this->reusable = false;
221
222            throw $e;
223        } finally {
224            $this->finish($scope, $request);
225        }
226    }
227
228    /**
229     * Handles exactly one request in its own container scope, emits the
230     * response, ends the request, then runs the teardown. Throwables that
231     * escape the error handler or the emitter are logged and answered with a
232     * minimal 500 response if nothing was sent yet; they do not leave this
233     * method.
234     */
235    public function run(?Request $request = null): Response|false
236    {
237        $bufferLevel = ob_get_level();
238        $scope = $this->container->scope();
239
240        try {
241            $request ??= $this->factory->serverRequest();
242            $response = $this->respond($request, $scope);
243
244            return $this->emitter->emit($response, $request->getMethod() === 'HEAD') ? $response : false;
245        } catch (Throwable $e) {
246            $this->reusable = false;
247            $this->report('Unhandled exception', $e, $request);
248            $this->recordServerException($e);
249            // The method is unknown if creating the request failed.
250            $this->emitFailure($bufferLevel, $e, $request?->getMethod() === 'HEAD');
251
252            return false;
253        } finally {
254            $this->endRequest();
255            $this->finish($scope, $request);
256        }
257    }
258
259    /**
260     * Runs the app in the runtime that started the script: as a FrankenPHP
261     * worker, which handles requests until it retires, or for exactly one
262     * request (PHP-FPM, FrankenPHP's classic mode, the CLI server).
263     *
264     * Register everything before calling it. The first request seals the
265     * container, so later registrations fail.
266     *
267     * @param ?int $maxRequests Worker only: retire after this many requests; 0 never.
268     *     Defaults to the CELEMA_WORKER_MAX_REQUESTS environment variable, else 0.
269     * @param ?int $maxMemory Worker only: retire above this many bytes of memory; 0 never.
270     *     Defaults to the CELEMA_WORKER_MAX_MEMORY environment variable, else 80 % of memory_limit.
271     */
272    public function serve(?int $maxRequests = null, ?int $maxMemory = null): Response|false
273    {
274        if (isset($_SERVER['FRANKENPHP_WORKER']) && function_exists('frankenphp_handle_request')) {
275            return FrankenPhpWorker::configure(
276                frankenphp_handle_request(...),
277                $maxRequests,
278                $maxMemory,
279            )->serve($this);
280        }
281
282        return $this->run();
283    }
284
285    /**
286     * Whether the app may handle further requests in the same process.
287     *
288     * @internal
289     */
290    public function reusable(): bool
291    {
292        return $this->reusable;
293    }
294
295    protected function respond(Request $request, Container $scope): Response
296    {
297        $handler = new RoutingHandler(
298            $this->router,
299            $this->dispatcher,
300            $scope,
301        );
302
303        return $this->errorHandler
304            ? $this->errorHandler->process($request, $handler)
305            : $handler->handle($request);
306    }
307
308    /**
309     * Sends the emitted response to the client and closes the connection;
310     * the script goes on with the teardown. PHP-FPM and FrankenPHP, in both
311     * modes, provide the function; elsewhere the client waits for the teardown.
312     */
313    protected function endRequest(): void
314    {
315        if (function_exists('fastcgi_finish_request')) {
316            fastcgi_finish_request();
317        }
318    }
319
320    /**
321     * Resets the request scope, then runs the teardown hooks. Every step is
322     * attempted; failures are logged and make the app unusable for further
323     * requests, but never replace the request's response or exception.
324     */
325    protected function finish(Container $scope, ?Request $request): void
326    {
327        $failures = [];
328
329        try {
330            $scope->reset();
331        } catch (ResetFailed $e) {
332            $failures = $e->failures;
333        }
334
335        foreach ($this->teardownHooks as $hook) {
336            try {
337                $hook();
338            } catch (Throwable $e) {
339                $failures[] = $e;
340            }
341        }
342
343        foreach ($failures as $failure) {
344            $this->reusable = false;
345            $this->report('Request teardown failed', $failure, $request);
346        }
347    }
348
349    /**
350     * Logs a failure the error handler did not answer, naming the request
351     * it belongs to. The request is unknown when creating it failed.
352     */
353    protected function report(string $message, Throwable $exception, ?Request $request): void
354    {
355        try {
356            /** @var mixed $logger */
357            $logger = $this->container->has(Logger::class) ? $this->container->get(Logger::class) : null;
358        } catch (Throwable $e) {
359            // A logger that cannot be resolved must not keep the failure from
360            // being answered or the request from being torn down.
361            error_log('Logging failed: ' . (string) $e);
362            $logger = null;
363        }
364
365        $values = [];
366
367        if ($request !== null) {
368            $message .= ' for {method} {path}';
369            $values = Log::request($request);
370        }
371
372        Log::write($logger instanceof Logger ? $logger : null, LogLevel::CRITICAL, $message, $exception, $values);
373    }
374
375    protected function recordServerException(Throwable $exception): void
376    {
377        // Shows the exception in the celema/server request log, as the error
378        // handler does for the exceptions it renders.
379        if (class_exists(ServerConsole::class)) {
380            ServerConsole::recordException($exception, trace: true);
381        }
382    }
383
384    /**
385     * Answers with a minimal 500 response if the failed request sent nothing
386     * yet. Like PHP for an uncaught exception, it shows the exception only
387     * while `display_errors` sends errors to the output, as in development.
388     */
389    protected function emitFailure(int $bufferLevel, Throwable $exception, bool $withoutBody = false): void
390    {
391        $body = '500 Internal Server Error';
392        $display = strtolower((string) ini_get('display_errors'));
393
394        if ($display !== 'stderr' && filter_var($display, FILTER_VALIDATE_BOOL) || $display === 'stdout') {
395            $body .= "\n\n" . (string) $exception;
396        }
397
398        $response = $this->factory->response(500)->withHeader('Content-Type', 'text/plain; charset=utf-8');
399        $response->getBody()->write($body);
400
401        Fallback::emit($this->emitter, $response, $bufferLevel, $withoutBody);
402    }
403}