Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
66 / 68
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebApp
97.06% covered (success)
97.06%
66 / 68
90.91% covered (success)
90.91%
10 / 11
22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 process
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 redirect
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 sendContent
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 sendError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 useRouteAttributes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 useJwtAuth
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 loadErrorPageContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isWeb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handleException
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2
3namespace Dynart\Micro;
4
5use Dynart\Micro\AttributeHandler\AllowAnonymousAttributeHandler;
6use Dynart\Micro\AttributeHandler\AuthorizeAttributeHandler;
7use Dynart\Micro\AttributeHandler\RouteAttributeHandler;
8use Dynart\Micro\Middleware\AttributeProcessor;
9use Dynart\Micro\Middleware\JwtValidator;
10
11/**
12 * Handles HTTP request/response
13 * @package Dynart\Micro
14 */
15class WebApp extends AbstractApp {
16
17    const CONFIG_ERROR_PAGES_FOLDER = 'app.error_pages_folder';
18    const CONFIG_USE_ROUTE_ATTRIBUTES = 'app.use_route_attributes';
19    const HEADER_CONTENT_TYPE = 'Content-Type';
20    const HEADER_LOCATION = 'Location';
21    const CONTENT_TYPE_HTML = 'text/html; charset=UTF-8';
22    const CONTENT_TYPE_JSON = 'application/json';
23    const ERROR_CONTENT_PLACEHOLDER = '<!-- content -->';
24    const EVENT_ROUTE_MATCHED = 'webapp:route_matched';
25
26    protected RouterInterface $router;
27    protected ResponseInterface $response;
28
29    public function __construct(array $configPaths) {
30        parent::__construct($configPaths);
31        Micro::add(RequestInterface::class, Request::class);
32        Micro::add(ResponseInterface::class, Response::class);
33        Micro::add(RouterInterface::class, Router::class);
34        Micro::add(SessionInterface::class, Session::class);
35        Micro::add(ViewInterface::class, View::class);
36    }
37
38    /**
39     * Inits the WebApp
40     *
41     * Gets the `$router` and the `$response` instances and initializes the RouteAttributeHandler
42     * if the `app.use_route_attributes` is true (default).
43     */
44    public function init(): void {
45        $this->router = Micro::get(RouterInterface::class);
46        $this->response = Micro::get(ResponseInterface::class);
47        if ($this->config?->get(self::CONFIG_USE_ROUTE_ATTRIBUTES, true) ?? true) {
48            $this->useRouteAttributes();
49        }
50    }
51
52    /**
53     * Runs the current route
54     *
55     * Matches the current route,  emits the `webapp:route_matched` signal
56     * then calls the right callable and sends back the returned content.
57     *
58     * If no route found sends a 404.
59     */
60    public function process(): void {
61        list($callable, $params) = $this->router->matchCurrentRoute();
62        if ($callable) {
63            $callable = Micro::getCallable($callable);
64            $this->eventService->emit(self::EVENT_ROUTE_MATCHED, [$callable, $params]);
65            $content = call_user_func_array($callable, $params);
66            $this->sendContent($content);
67        } else {
68            $this->sendError(404);
69        }
70    }
71
72    /**
73     * Redirects to the give location and parameters
74     *
75     * Clears the current headers, then sends back a `Location` header and empty body, then finishes the request.
76     * If `$location` NOT starts with `http` will be converted to a full URL with `Router::url`
77     */
78    public function redirect(string $location, array $params = []): void {
79        $url = str_starts_with($location, 'http') ? $location : $this->router->url($location, $params);
80        $this->response->clearHeaders();
81        $this->response->setHeader(self::HEADER_LOCATION, $url);
82        $this->response->send();
83        $this->finish();
84    }
85
86    /**
87     * Sends the content
88     *
89     * If the `$content` is an array will be encoded to a JSON string.
90     * If no `Content-Type` header was added will be set to
91     * `html/text; charset=UTF-8` (string) or `application/json` (array).
92     */
93    public function sendContent(mixed $content): void {
94        $rawContent = is_array($content)
95            ? json_encode($content)
96            : $content;
97        if (!$this->response->header(self::HEADER_CONTENT_TYPE)) {
98            $this->response->setHeader(self::HEADER_CONTENT_TYPE, is_array($content)
99                ? self::CONTENT_TYPE_JSON
100                : self::CONTENT_TYPE_HTML);
101        }
102        $this->response->send($rawContent);
103    }
104
105    /**
106     * Sends an error response
107     * @param int $code The error code
108     * @param string $content The error content
109     */
110    public function sendError(int $code, string $content = ''): void {
111        if ($this->isWeb()) { // Because of testing in cli (fastest solution)
112            http_response_code($code);
113        }
114        $pageContent = str_replace(self::ERROR_CONTENT_PLACEHOLDER, $content, $this->loadErrorPageContent($code));
115        $this->finish($pageContent);
116    }
117
118    /**
119     * Initializes the RouteAttributeHandler for the Route attributes
120     */
121    public function useRouteAttributes(): void {
122        $this->addMiddleware(AttributeProcessor::class);
123        Micro::add(RouteAttributeHandler::class);
124        $processor = Micro::get(AttributeProcessor::class);
125        $processor->add(RouteAttributeHandler::class);
126    }
127
128    /**
129     * Initializes the JwtValidator middleware and the Authorize and AllowAnonymous attributes,
130     * adds the JwtAuthInterface.
131     */
132    public function useJwtAuth(): void {
133        $this->addMiddleware(AttributeProcessor::class);
134        $this->addMiddleware(JwtValidator::class);
135        Micro::add(JwtAuthInterface::class, JwtAuth::class);
136        Micro::add(AuthorizeAttributeHandler::class);
137        Micro::add(AllowAnonymousAttributeHandler::class);
138        $processor = Micro::get(AttributeProcessor::class);
139        $processor->add(AuthorizeAttributeHandler::class);
140        $processor->add(AllowAnonymousAttributeHandler::class);
141    }
142
143    /**
144     * If it exists, loads the content of an error HTML page otherwise
145     * returns the HTML comment for the error placeholder
146     *
147     * @param int $code The HTTP status code for the error
148     * @return string The content of the HTML file or the HTML comment for the error placeholder
149     */
150    protected function loadErrorPageContent(int $code): string {
151        $dir = $this->config->get(self::CONFIG_ERROR_PAGES_FOLDER);
152        if ($dir) {
153            $path = $this->config->getFullPath($dir.'/'.$code.'.html');
154            if (file_exists($path)) {
155                return file_get_contents($path);
156            }
157        }
158        return self::ERROR_CONTENT_PLACEHOLDER;
159    }
160
161    /**
162     * Returns true if the call is from the web
163     * @return bool
164     */
165    protected function isWeb(): bool {
166        return http_response_code() !== false;
167    }
168
169    /**
170     * Handles the exception
171     *
172     * Calls the parent exception handler, then calls the sendError with HTTP error 500.
173     * Sets the content for the error placeholder if the environment is not production.
174     *
175     * @param \Exception $e The exception for handling
176     */
177    protected function handleException(\Exception $e): void {
178        if ($e instanceof AuthorizationException) {
179            $this->sendError($e->getCode());
180            return;
181        }
182        parent::handleException($e);
183        $env = $this->config->get(AbstractApp::CONFIG_ENVIRONMENT, AbstractApp::PRODUCTION_ENVIRONMENT);
184        if ($env != AbstractApp::PRODUCTION_ENVIRONMENT) {
185            $type = get_class($e);
186            $file = $e->getFile();
187            $line = $e->getLine();
188            $message = $e->getMessage();
189            $trace = $e->getTraceAsString();
190            $content = "<h2>$type</h2>\n<p>In <b>$file</b> on <b>line $line</b> with message: $message</p>\n";
191            $content .= "<h3>Stacktrace:</h3>\n<p>".str_replace("\n", "<br>\n", $trace)."</p>";
192        } else {
193            $content = '';
194        }
195        $this->sendError(500, $content);
196    }
197
198}