Test Failed
Pull Request — master (#3)
by Mihail
02:10
created

App   A

Complexity

Total Complexity 42

Size/Duplication

Total Lines 297
Duplicated Lines 0 %

Test Coverage

Coverage 81.88%

Importance

Changes 24
Bugs 3 Features 2
Metric Value
eloc 135
c 24
b 3
f 2
dl 0
loc 297
ccs 122
cts 149
cp 0.8188
rs 9.0399
wmc 42

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