Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
102 / 102 |
|
98.90% |
90 / 91 |
|
45.35% |
39 / 86 |
|
96.55% |
28 / 29 |
CRAP | |
0.00% |
0 / 1 |
| App | |
100.00% |
102 / 102 |
|
98.90% |
90 / 91 |
|
45.35% |
39 / 86 |
|
100.00% |
29 / 29 |
475.56 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| load | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| create | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| router | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| factory | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addRoute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| group | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| staticRoute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getMiddleware | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| errorHandler | |
100.00% |
3 / 3 |
|
100.00% |
3 / 3 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
2.50 | |||
| emitter | |
100.00% |
3 / 3 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| middleware | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| before | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| after | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logger | |
100.00% |
3 / 3 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| container | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| initializeContainer | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| teardown | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
100.00% |
6 / 6 |
|
100.00% |
5 / 5 |
|
0.00% |
0 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| run | |
100.00% |
13 / 13 |
|
100.00% |
9 / 9 |
|
0.00% |
0 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| serve | |
100.00% |
7 / 7 |
|
100.00% |
5 / 5 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
4.12 | |||
| reusable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| respond | |
100.00% |
8 / 8 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| endRequest | |
100.00% |
2 / 2 |
|
100.00% |
3 / 3 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
2.50 | |||
| finish | |
100.00% |
11 / 11 |
|
100.00% |
13 / 13 |
|
13.64% |
3 / 22 |
|
100.00% |
1 / 1 |
21.10 | |||
| report | |
100.00% |
9 / 9 |
|
100.00% |
12 / 12 |
|
33.33% |
4 / 12 |
|
100.00% |
1 / 1 |
12.41 | |||
| recordServerException | |
100.00% |
2 / 2 |
|
83.33% |
5 / 6 |
|
25.00% |
1 / 4 |
|
100.00% |
1 / 1 |
3.69 | |||
| emitFailure | |
100.00% |
7 / 7 |
|
100.00% |
7 / 7 |
|
50.00% |
4 / 8 |
|
100.00% |
1 / 1 |
6.00 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celema\Core; |
| 6 | |
| 7 | use Celema\Container\Container; |
| 8 | use Celema\Container\Entry; |
| 9 | use Celema\Container\Exception\ResetFailed; |
| 10 | use Celema\Core\Emitter\Emitter; |
| 11 | use Celema\Core\Emitter\Fallback; |
| 12 | use Celema\Core\Emitter\Sapi; |
| 13 | use Celema\Core\Error\Handler as ErrorHandler; |
| 14 | use Celema\Core\Error\Log; |
| 15 | use Celema\Core\Factory\Factory; |
| 16 | use Celema\Core\Factory\Nyholm; |
| 17 | use Celema\Core\Runtime\FrankenPhpWorker; |
| 18 | use Celema\Router\AddsRoutes; |
| 19 | use Celema\Router\After; |
| 20 | use Celema\Router\Before; |
| 21 | use Celema\Router\Dispatcher; |
| 22 | use Celema\Router\Route; |
| 23 | use Celema\Router\RouteAdder; |
| 24 | use Celema\Router\Router; |
| 25 | use Celema\Router\RoutingHandler; |
| 26 | use Celema\Server\Console as ServerConsole; |
| 27 | use Closure; |
| 28 | use Override; |
| 29 | use Psr\Container\ContainerInterface as PsrContainer; |
| 30 | use Psr\Http\Message\ResponseInterface as Response; |
| 31 | use Psr\Http\Message\ServerRequestInterface as Request; |
| 32 | use Psr\Http\Server\MiddlewareInterface as Middleware; |
| 33 | use Psr\Http\Server\RequestHandlerInterface as RequestHandler; |
| 34 | use Psr\Log\LoggerInterface as Logger; |
| 35 | use Psr\Log\LogLevel; |
| 36 | use Throwable; |
| 37 | |
| 38 | /** @api */ |
| 39 | class 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 | } |