Passed
Push — master ( 6a5936...88a431 )
by Mihail
12:09
created

App   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 306
Duplicated Lines 0 %

Test Coverage

Coverage 81.17%

Importance

Changes 22
Bugs 3 Features 1
Metric Value
eloc 142
dl 0
loc 306
ccs 125
cts 154
cp 0.8117
rs 8.8798
c 22
b 3
f 1
wmc 44

16 Methods

Rating   Name   Duplication   Size   Complexity  
A withoutErrorHandler() 0 4 1
A httpErrorHandler() 0 6 1
A __construct() 0 12 1
A initialize() 0 22 5
A next() 0 5 1
A __invoke() 0 24 3
A withErrorSerializer() 0 4 1
A findErrorHandler() 0 16 5
A handle() 0 8 2
A handleException() 0 14 3
A composeErrorResponse() 0 24 3
A group() 0 12 2
A phpErrorHandler() 0 17 1
A route() 0 21 3
B responder() 0 34 8
A withErrorHandler() 0 12 4

How to fix   Complexity   

Complex Class

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);
2
3
namespace Koded\Framework;
4
5
use Closure;
6
use Error;
7
use Exception;
8
use InvalidArgumentException;
9
use Koded\DIContainer;
10
use Koded\Framework\Middleware\{CallableMiddleware, CorsMiddleware, GzipMiddleware};
11
use Koded\Http\Interfaces\{HttpStatus, Request, Response};
12
use Koded\Stdlib\Configuration;
13
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
14
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
15
use Psr\Log\LoggerInterface;
16
use Whoops\Handler\PrettyPageHandler;
17
use stdClass;
18
use Throwable;
19
use TypeError;
20
use Whoops\Run as WoopsRunner;
21
use function array_keys;
22
use function array_merge;
23
use function array_values;
24
use function call_user_func_array;
25
use function date_default_timezone_set;
26
use function error_log;
27
use function function_exists;
28
use function get_class;
29
use function get_debug_type;
30
use function get_parent_class;
31
use function is_a;
32
use function is_callable;
33
use function method_exists;
34
use function rawurldecode;
35
use function sprintf;
36
37 1
(new WoopsRunner)
38 1
    ->prependHandler(new PrettyPageHandler)
39 1
    ->register();
40
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
Bug introduced by
The property container is declared read-only in Koded\Framework\App.
Loading history...
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
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return Koded\Framework\App. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
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
183
    {
184 1
        unset($this->handlers[$type]);
185 1
        return $this;
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(
316
        ServerRequestInterface $request,
317
        ResponseInterface &$response,
318
        HTTPError $ex): void
319
    {
320 3
        $this->composeErrorResponse($request, $response, $ex);
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');
330
331 4
        foreach ($ex->getHeaders() as $name => $value) {
332 1
            $response = $response->withHeader($name, $value);
333
        }
334
        // Process middleware
335 4
        $this->responder = fn() => $response;
336 4
        $this->initialize(null);
337 4
        $response = $this->handle($request);
338
339
        try {
340 4
            $response = call_user_func_array(
341 4
                $this->container->get('$errorSerializer'),
342 4
                [$request, &$response, $ex]
343 4
            );
344
        } catch (Throwable) {
345
            // Fallback if error handler does not exist
346
            $response = default_serialize_error($request, $response, $ex);
347
        }
348
    }
349
}
350