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