Passed
Push — master ( b7f261...78e61b )
by Mihail
02:02
created

App   A

Complexity

Total Complexity 42

Size/Duplication

Total Lines 286
Duplicated Lines 0 %

Test Coverage

Coverage 81.89%

Importance

Changes 20
Bugs 2 Features 0
Metric Value
eloc 128
c 20
b 2
f 0
dl 0
loc 286
ccs 104
cts 127
cp 0.8189
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 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 17 3
A group() 0 12 2
A phpErrorHandler() 0 17 1
A route() 0 9 1
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\{JsonResponseHandler, 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
    ->prependHandler(new JsonResponseHandler)
40 1
    ->register();
41
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
Bug introduced by
The property container is declared read-only in Koded\Framework\App.
Loading history...
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);
0 ignored issues
show
Bug introduced by
The property responder is declared read-only in Koded\Framework\App.
Loading history...
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
171
    {
172 1
        unset($this->handlers[$type]);
173 1
        return $this;
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(
304
        ServerRequestInterface $request,
305
        ResponseInterface &$response,
306
        HTTPError $ex): void
307
    {
308 1
        $this->composeErrorResponse($request, $response, $ex);
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) {
318
            $response = $response->withHeader($name, $value);
319
        }
320
        try {
321 2
            $response = call_user_func_array(
322 2
                $this->container->get('$errorSerializer'),
323 2
                [$request, &$response, $ex]
324
            );
325
        } catch (Throwable) {
326
            // Fallback if error handler does not exist
327
            $response = default_serialize_error($request, $response, $ex);
328
        }
329
    }
330
}
331