| Total Complexity | 42 | 
| Total Lines | 286 | 
| Duplicated Lines | 0 % | 
| Coverage | 81.89% | 
| Changes | 20 | ||
| Bugs | 2 | Features | 0 | 
Complex classes like App often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use App, and based on these observations, apply Extract Interface, too.
| 1 | <?php declare(strict_types=1); | ||
| 42 | class App implements RequestHandlerInterface | ||
| 43 | { | ||
| 44 | private int $offset = 0; | ||
| 45 | /** @var array<int, MiddlewareInterface> */ | ||
| 46 | private array $stack = []; | ||
| 47 | /** @var array<string, array> */ | ||
| 48 | private array $explicit = []; | ||
| 49 | /** @var array<string, callable|null> */ | ||
| 50 | private array $handlers = []; | ||
| 51 | private readonly mixed $responder; | ||
| 52 | private readonly DIContainer $container; | ||
| 53 | |||
| 54 | 21 | public function __construct( | |
| 55 | array $modules = [], | ||
| 56 | Configuration|string $config = '', | ||
| 57 | private array $middleware = [], | ||
| 58 | private mixed $renderer = 'start_response') | ||
| 59 |     { | ||
| 60 | 21 |         date_default_timezone_set('UTC'); | |
| 61 | 21 | $this->withErrorHandler(HTTPError::class, 'static::httpErrorHandler'); | |
| 62 | 21 | $this->withErrorHandler(Exception::class, 'static::phpErrorHandler'); | |
| 63 | 21 | $this->withErrorHandler(Error::class, 'static::phpErrorHandler'); | |
| 64 | 21 | $this->container = new DIContainer(new Module($config), ...$modules); | |
| 1 ignored issue–
                            show | |||
| 65 | 21 | $this->middleware = [new GzipMiddleware, ...$middleware, CorsMiddleware::class]; | |
| 66 | } | ||
| 67 | |||
| 68 | /** | ||
| 69 | * @return mixed | ||
| 70 | * @throws mixed | ||
| 71 | */ | ||
| 72 | 21 | public function __invoke(): mixed | |
| 73 |     { | ||
| 74 |         try { | ||
| 75 | 21 | $request = $this->container | |
| 76 | 21 | ->new(ServerRequestInterface::class) | |
| 77 | 21 |                 ->withAttribute('@media', $this->container->get(Configuration::class)->get('media')); | |
| 78 | |||
| 79 | 21 | $this->responder = $this->responder($request, $uriTemplate); | |
| 80 | 21 | $this->initialize($uriTemplate); | |
| 81 | 21 | $response = $this->handle($request); | |
| 82 | 3 |         } catch (Throwable $exception) { | |
| 83 | // [NOTE]: On exception, the state of the immutable request/response | ||
| 84 | // objects are not updated through the middleware "request phase", | ||
| 85 | // therefore the object attributes (and other properties) are lost | ||
| 86 | 3 | $response = $this->container->get(ResponseInterface::class); | |
| 87 | 3 |             if (false === $this->handleException($request, $response, $exception)) { | |
| 88 | 1 | throw $exception; | |
| 89 | } | ||
| 90 | } | ||
| 91 | // Share the response object for (custom) renderers | ||
| 92 | 20 | $this->container->share($response); | |
| 93 | // [OPTIMIZATION]: Consider optimizing this | ||
| 94 | 20 | $this->container->bind(Response::class, $response::class); | |
| 95 | 20 | return ($this->container)($this->renderer); | |
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * @param ServerRequestInterface $request | ||
| 100 | * @return ResponseInterface | ||
| 101 | * @internal | ||
| 102 | */ | ||
| 103 | 21 | public function handle(ServerRequestInterface $request): ResponseInterface | |
| 104 |     { | ||
| 105 | 21 |         if (isset($this->stack[$this->offset])) { | |
| 106 |             //\error_log('[mw:' . $this->offset . ']> ' . \get_debug_type($this->stack[$this->offset])); | ||
| 107 | 21 | return $this->stack[$this->offset]->process($request, $this->next()); | |
| 108 | } | ||
| 109 | 21 | $this->container->share($request); | |
| 110 | 21 | return ($this->container)($this->responder); | |
| 111 | } | ||
| 112 | |||
| 113 | /** | ||
| 114 | * Create a route for URI. | ||
| 115 | * | ||
| 116 | * @param string $uriTemplate The URI template | ||
| 117 | * @param object|string $resource A PHP callable | ||
| 118 | * @param array<MiddlewareInterface|string> $middleware [optional] List of middlewares for this route | ||
| 119 | * @param bool $explicit [optional] If TRUE replace the global middlewares | ||
| 120 | * @return self | ||
| 121 | */ | ||
| 122 | 20 | public function route( | |
| 123 | string $uriTemplate, | ||
| 124 | object|string $resource, | ||
| 125 | array $middleware = [], | ||
| 126 | bool $explicit = false): App | ||
| 127 |     { | ||
| 128 | 20 | $this->container->get(Router::class)->route($uriTemplate, $resource); | |
| 129 | 20 | $this->explicit[$uriTemplate] = [$explicit, $middleware]; | |
| 130 | 20 | return $this; | |
| 131 | } | ||
| 132 | |||
| 133 | /** | ||
| 134 | * Group multiple routes (adds prefix to all). | ||
| 135 | * See App::route() method. | ||
| 136 | * | ||
| 137 | * @param string $prefix URI prefix for all routes in this group | ||
| 138 | * @param array $routes A list of routes (@see App::route()) | ||
| 139 | * @param array<int, MiddlewareInterface|string> $middleware Additional middleware for all routes | ||
| 140 | * @return self | ||
| 141 | */ | ||
| 142 | public function group( | ||
| 143 | string $prefix, | ||
| 144 | array $routes, | ||
| 145 | array $middleware = []): App | ||
| 146 |     { | ||
| 147 |         foreach ($routes as $route) { | ||
| 148 | // (template, resource, middleware, explicit) | ||
| 149 | $route += ['', '', [], false]; | ||
| 150 | [$uriTemplate, $resource, $mw, $explicit] = $route; | ||
| 151 | $this->route($prefix . $uriTemplate, $resource, array_merge($middleware, $mw), $explicit); | ||
| 152 | } | ||
| 153 | return $this; | ||
| 154 | } | ||
| 155 | |||
| 156 | 21 | public function withErrorHandler(string $type, callable|null $handler): App | |
| 157 |     { | ||
| 158 | 21 |         if (false === is_a($type, Throwable::class, true)) { | |
| 159 |             throw new TypeError('"type" must be an exception type', HttpStatus::CONFLICT); | ||
| 160 | } | ||
| 161 | 21 |         if (null === $handler && false === method_exists($type, 'handle')) { | |
| 162 |             throw new TypeError('Error handler must either be specified explicitly,' . | ||
| 163 | ' or defined as a static method named "handle" that is a member of' . | ||
| 164 | ' the given exception type', HttpStatus::NOT_IMPLEMENTED); | ||
| 165 | } | ||
| 166 | 21 | $this->handlers[$type] = $handler; | |
| 167 | 21 | return $this; | |
| 168 | } | ||
| 169 | |||
| 170 | 1 | public function withoutErrorHandler(string $type): App | |
| 174 | } | ||
| 175 | |||
| 176 | public function withErrorSerializer(callable $serializer): App | ||
| 177 |     { | ||
| 178 |         $this->container->named('$errorSerializer', $serializer); | ||
| 179 | return $this; | ||
| 180 | } | ||
| 181 | |||
| 182 | 21 | private function next(): RequestHandlerInterface | |
| 183 |     { | ||
| 184 | 21 | $self = clone $this; | |
| 185 | 21 | $self->offset++; | |
| 186 | 21 | return $self; | |
| 187 | } | ||
| 188 | |||
| 189 | 21 | private function initialize(?string $uriTemplate): void | |
| 190 |     { | ||
| 191 | 21 | $this->offset = 0; | |
| 192 | 21 | $this->stack = []; | |
| 193 | 21 |         if (empty($uriTemplate)) { | |
| 194 | // Always support CORS requests | ||
| 195 | 1 | $this->explicit[$uriTemplate] = [true, [CorsMiddleware::class]]; | |
| 196 | } | ||
| 197 | 21 | [$explicit, $middleware] = $this->explicit[$uriTemplate] + [true]; | |
| 198 | 21 | $this->middleware = false === $explicit ? [...$this->middleware, ...$middleware] : $middleware; | |
| 199 | 21 |         foreach ($this->middleware as $middleware) { | |
| 200 | 21 | $class = 'string' === get_debug_type($middleware) ? $middleware : get_class($middleware); | |
| 201 | 21 |             $this->stack[$class] = match (true) { | |
| 202 | 21 | $middleware instanceof MiddlewareInterface => $middleware, | |
| 203 | 21 | is_a($middleware, MiddlewareInterface::class, true) => $this->container->new($middleware), | |
| 204 | is_callable($middleware) => new CallableMiddleware($middleware), | ||
| 205 | default => throw new InvalidArgumentException( | ||
| 206 |                     sprintf('Middleware "%s" must implement %s', $class, MiddlewareInterface::class) | ||
| 207 | ) | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | 21 | $this->stack = array_values($this->stack); | |
| 211 | } | ||
| 212 | |||
| 213 | 21 | private function responder( | |
| 214 | ServerRequestInterface &$request, | ||
| 215 | string|null &$uriTemplate): callable | ||
| 216 |     { | ||
| 217 | 21 | $path = rawurldecode($request->getUri()->getPath()); | |
| 218 | 21 | $match = $this->container->get(Router::class)->match($path); | |
| 219 | 21 | $uriTemplate = $match['template'] ?? null; | |
| 220 | 21 | $resource = $match['resource'] ?? null; | |
| 221 | 21 | $allowed = array_keys(map_http_methods($resource ?? new stdClass)); | |
| 222 | 21 |         $request = $request->withAttribute('@http_methods', $allowed); | |
| 223 | 21 |         foreach ($match['params'] ?? [] as $name => $value) { | |
| 224 | $request = $request->withAttribute($name, $value); | ||
| 225 | } | ||
| 226 | 21 |         $this->container->get(LoggerInterface::class)->debug('> {method} {dir}', [ | |
| 227 | 21 | 'method' => $request->getMethod(), | |
| 228 | 'dir' => $path | ||
| 229 | ]); | ||
| 230 | 21 |         if (Request::OPTIONS === $method = $request->getMethod()) { | |
| 231 | 5 | return create_options_response($allowed); | |
| 232 | } | ||
| 233 | 16 |         if (empty($resource)) { | |
| 234 | return path_not_found($path); | ||
| 235 | } | ||
| 236 | 16 |         if (Request::HEAD === $method) { | |
| 237 | return head_response((string)$request->getUri(), $allowed); | ||
| 238 | } | ||
| 239 | 16 |         if ($resource instanceof Closure || function_exists($resource)) { | |
| 240 | 3 | return $resource; | |
| 241 | } | ||
| 242 | 13 | $responder = $this->container->new($resource); | |
| 243 | 13 |         if (false === method_exists($responder, $method)) { | |
| 244 | return method_not_allowed($allowed); | ||
| 245 | } | ||
| 246 | 13 | return [$responder, $method]; | |
| 247 | } | ||
| 248 | |||
| 249 | 3 | private function handleException( | |
| 250 | ServerRequestInterface $request, | ||
| 251 | ResponseInterface &$response, | ||
| 252 | Throwable $ex): bool | ||
| 253 |     { | ||
| 254 | 3 |         if (!$handler = $this->findErrorHandler($ex)) { | |
| 255 | 1 | return false; | |
| 256 | } | ||
| 257 |         try { | ||
| 258 | 2 | call_user_func_array($handler, [$request, &$response, $ex]); | |
| 259 |         } catch (HTTPError $error) { | ||
| 260 | $this->composeErrorResponse($request, $response, $error); | ||
| 261 | } | ||
| 262 | 2 | return true; | |
| 263 | } | ||
| 264 | |||
| 265 | 3 | private function findErrorHandler($ex): callable|null | |
| 266 |     { | ||
| 267 | 3 | $parents = [get_debug_type($ex)]; | |
| 268 | // Iterate the class inheritance chain | ||
| 269 | 3 |         (static function($class) use (&$parents) { | |
| 270 | 3 |             while ($class = get_parent_class($class)) { | |
| 271 | 1 | $parents[] = $class; | |
| 272 | } | ||
| 273 | })($ex); | ||
| 274 | // Find the parent that matches the exception type | ||
| 275 | 3 |         foreach ($parents as $parent) { | |
| 276 | 3 |             if (isset($this->handlers[$parent]) && is_a($ex, $parent, true)) { | |
| 277 | 2 | return $this->handlers[$parent]; | |
| 278 | } | ||
| 279 | } | ||
| 280 | 1 | return null; | |
| 281 | } | ||
| 282 | |||
| 283 | 1 | private function phpErrorHandler( | |
| 284 | ServerRequestInterface $request, | ||
| 285 | ResponseInterface &$response, | ||
| 286 | Throwable $ex): void | ||
| 287 |     { | ||
| 288 | 1 |         error_log(sprintf("[%s] %s\n%s", | |
| 289 | 1 | $title = get_debug_type($ex), | |
| 290 | 1 | $ex->getMessage(), | |
| 291 | 1 | $ex->getTraceAsString())); | |
| 292 | |||
| 293 | 1 | $this->composeErrorResponse( | |
| 294 | $request, | ||
| 295 | $response, | ||
| 296 | 1 | new HTTPError(HTTPError::status($ex, HttpStatus::CONFLICT), | |
| 297 | title: $title, | ||
| 298 | 1 | detail: $ex->getMessage(), | |
| 299 | previous: $ex | ||
| 300 | )); | ||
| 301 | } | ||
| 302 | |||
| 303 | 1 | private function httpErrorHandler( | |
| 309 | } | ||
| 310 | |||
| 311 | 2 | private function composeErrorResponse( | |
| 312 | ServerRequestInterface $request, | ||
| 313 | ResponseInterface &$response, | ||
| 314 | HTTPError $ex): void | ||
| 315 |     { | ||
| 316 | 2 | $response = $response->withStatus($ex->getCode()); | |
| 317 | 2 |         foreach ($ex->getHeaders() as $name => $value) { | |
| 328 | } | ||
| 329 | } | ||
| 330 | } | ||
| 331 |