Passed
Push — master ( a154a4...db1aba )
by Mihail
11:52
created

App::group()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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