Passed
Push — master ( 724c5e...51e478 )
by Mihail
02:14
created

App   A

Complexity

Total Complexity 42

Size/Duplication

Total Lines 297
Duplicated Lines 0 %

Test Coverage

Coverage 85.81%

Importance

Changes 23
Bugs 3 Features 1
Metric Value
eloc 135
dl 0
loc 297
ccs 127
cts 148
cp 0.8581
rs 9.0399
c 23
b 3
f 1
wmc 42

16 Methods

Rating   Name   Duplication   Size   Complexity  
A httpErrorHandler() 0 6 1
A __construct() 0 12 1
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 phpErrorHandler() 0 17 1
B responder() 0 34 8
A withoutErrorHandler() 0 4 1
A initialize() 0 24 5
A group() 0 16 2
A route() 0 9 1
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
39 1
if (false === getenv('PRODUCTION')) {
40 1
    (new WoopsRunner)
41 1
        ->prependHandler(new PrettyPageHandler)
42 1
        ->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
    private mixed $responder;
55
    private readonly DIContainer $container;
56
57 39
    public function __construct(
58
        array $modules = [],
59
        Configuration|string $config = '',
60
        private array $middleware = [],
61
        private mixed $renderer = 'start_response')
62
    {
63 39
        date_default_timezone_set('UTC');
64 39
        $this->withErrorHandler(HTTPError::class, [static::class, 'httpErrorHandler']);
65 39
        $this->withErrorHandler(Exception::class, [static::class, 'phpErrorHandler']);
66 39
        $this->withErrorHandler(Error::class, [static::class, 'phpErrorHandler']);
67 39
        $this->container = new DIContainer(new Module($config), ...$modules);
68 39
        $this->middleware = [new GzipMiddleware, ...$middleware, CorsMiddleware::class];
69
    }
70
71
    /**
72
     * @return mixed
73
     * @throws mixed
74
     */
75 36
    public function __invoke(): mixed
76
    {
77
        try {
78 36
            $request = $this->container
79 36
                ->new(ServerRequestInterface::class)
80 36
                ->withAttribute('@media', $this->container->get(Configuration::class)->get('media'));
81
82 36
            $this->responder = $this->responder($request, $uriTemplate);
83 34
            $this->initialize($uriTemplate);
84 34
            $response = $this->handle($request);
85 5
        } catch (Throwable $exception) {
86
            // [NOTE]: On exception, the state of the immutable request/response
87
            //  objects are not updated through the middleware "request phase",
88
            //  therefore the object attributes (and other properties) are lost
89 5
            $response = $this->container->get(ResponseInterface::class);
90 5
            if (false === $this->handleException($request, $response, $exception)) {
91 1
                throw $exception;
92
            }
93
        }
94
        // Share the response object for (custom) renderers
95 35
        $this->container->share($response);
96
        // [OPTIMIZATION]: Consider optimizing this
97 35
        $this->container->bind(Response::class, $response::class);
98 35
        return ($this->container)($this->renderer);
99
    }
100
101
    /**
102
     * @param ServerRequestInterface $request
103
     * @return ResponseInterface
104
     * @internal
105
     */
106 36
    public function handle(ServerRequestInterface $request): ResponseInterface
107
    {
108 36
        if (isset($this->stack[$this->offset])) {
109
            //\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 36
        $this->container->share($request);
113 36
        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
     * @param bool                              $explicit    [optional] If TRUE replace the global middlewares
123
     * @return self
124
     */
125 35
    public function route(
126
        string $uriTemplate,
127
        object|string $resource,
128
        array $middleware = [],
129
        bool $explicit = false): App
130
    {
131 35
        $this->container->get(Router::class)->route($uriTemplate, $resource);
132 35
        $this->explicit[$uriTemplate] = [$explicit, $middleware];
133 35
        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 39
    public function withErrorHandler(string $type, callable|null $handler): App
164
    {
165 39
        if (false === is_a($type, Throwable::class, true)) {
166
            throw new TypeError(__('koded.handler.wrong.type'), HttpStatus::CONFLICT);
167
        }
168 39
        if (null === $handler && false === method_exists($type, 'handle')) {
169
            throw new TypeError(__('koded.handler.missing'), HttpStatus::NOT_IMPLEMENTED);
170
        }
171 39
        $this->handlers[$type] = $handler;
172 39
        return $this;
173
    }
174
175 1
    public function withoutErrorHandler(string $type): App
176
    {
177 1
        unset($this->handlers[$type]);
178 1
        return $this;
179
    }
180
181
    public function withErrorSerializer(callable $serializer): App
182
    {
183
        $this->container->named('$errorSerializer', $serializer);
184
        return $this;
185
    }
186
187 36
    private function next(): RequestHandlerInterface
188
    {
189 36
        $self = clone $this;
190 36
        $self->offset++;
191 36
        return $self;
192
    }
193
194 36
    private function initialize(?string $uriTemplate): void
195
    {
196 36
        $this->offset = 0;
197 36
        $this->stack = [];
198 36
        if (empty($uriTemplate)) {
199
            // Always support CORS requests
200 4
            $this->explicit[$uriTemplate] = [true, [CorsMiddleware::class]];
201
        }
202 36
        [$explicit, $middleware] = $this->explicit[$uriTemplate] + [true];
203 36
        $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 36
                $middleware instanceof MiddlewareInterface => $middleware,
208 36
                is_a($middleware, MiddlewareInterface::class, true) => $this->container->new($middleware),
209 36
                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
        }
217 36
        $this->stack = array_values($this->stack);
218
    }
219
220 36
    private function responder(
221
        ServerRequestInterface &$request,
222
        string|null &$uriTemplate): callable
223
    {
224 36
        $path = rawurldecode($request->getUri()->getPath());
225 36
        $match = $this->container->get(Router::class)->match($path);
226 36
        $uriTemplate = $match['template'] ?? null;
227 36
        $resource = $match['resource'] ?? null;
228 36
        $allowed = array_keys(map_http_methods($resource ?? new stdClass));
229 36
        $request = $request->withAttribute('@http_methods', $allowed);
230 36
        foreach ($match['params'] ?? [] as $name => $value) {
231
            $request = $request->withAttribute($name, $value);
232
        }
233 36
        $this->container->get(LoggerInterface::class)->debug('> {method} {path}', [
234 36
            'method' => $request->getMethod(),
235 36
            'path' => $path
236 36
        ]);
237 36
        if ('OPTIONS' === $method = $request->getMethod()) {
238 7
            return create_options_response($allowed);
239
        }
240 29
        if (empty($resource)) {
241 1
            return path_not_found($path);
242
        }
243 28
        if ('HEAD' === $method) {
244
            return head_response((string)$request->getUri(), $allowed);
245
        }
246 28
        if ($resource instanceof Closure || function_exists($resource)) {
247 14
            return $resource;
248
        }
249 14
        $responder = $this->container->new($resource);
250 14
        if (false === method_exists($responder, $method)) {
251 1
            return method_not_allowed($allowed);
252
        }
253 13
        return [$responder, $method];
254
    }
255
256 5
    private function handleException(
257
        ServerRequestInterface $request,
258
        ResponseInterface &$response,
259
        Throwable $ex): bool
260
    {
261 5
        if (!$handler = $this->findErrorHandler($ex)) {
262 1
            return false;
263
        }
264
        try {
265 4
            call_user_func_array($handler, [$request, &$response, $ex]);
266
        } catch (HTTPError $error) {
267
            $this->composeErrorResponse($request, $response, $error);
268
        }
269 4
        return true;
270
    }
271
272 5
    private function findErrorHandler($ex): callable|null
273
    {
274 5
        $parents = [get_debug_type($ex)];
275
        // Iterate the class inheritance chain
276 5
        (static function($class) use (&$parents) {
277 5
            while ($class = get_parent_class($class)) {
278 3
                $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 4
                return $this->handlers[$parent];
285
            }
286
        }
287 1
        return null;
288
    }
289
290 1
    private function phpErrorHandler(
291
        ServerRequestInterface $request,
292
        ResponseInterface &$response,
293
        Throwable $ex): void
294
    {
295 1
        error_log(sprintf("[%s] %s\n%s",
296 1
                            $title = get_debug_type($ex),
297 1
                            $ex->getMessage(),
298 1
                            $ex->getTraceAsString()));
299
300 1
        $this->composeErrorResponse(
301 1
            $request,
302 1
            $response,
303 1
            new HTTPError(HTTPError::status($ex, HttpStatus::CONFLICT),
304 1
                title:    $title,
305 1
                detail:   $ex->getMessage(),
306 1
                previous: $ex
307 1
            ));
308
    }
309
310 3
    private function httpErrorHandler(
311
        ServerRequestInterface $request,
312
        ResponseInterface &$response,
313
        HTTPError $ex): void
314
    {
315 3
        $this->composeErrorResponse($request, $response, $ex);
316
    }
317
318 4
    private function composeErrorResponse(
319
        ServerRequestInterface $request,
320
        ResponseInterface &$response,
321
        HTTPError $ex): void
322
    {
323 4
        $response = $response->withStatus($ex->getCode())
324 4
            ->withAddedHeader('Vary', 'Content-Type');
325
326 4
        foreach ($ex->getHeaders() as $name => $value) {
327 1
            $response = $response->withHeader($name, $value);
328
        }
329
        // Process middleware
330 4
        $this->responder = fn() => $response;
331 4
        $this->initialize(null);
332 4
        $response = $this->handle($request);
333
334
        try {
335 4
            $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
            $response = default_serialize_error($request, $response, $ex);
342
        }
343
    }
344
}
345