Site::getResponse()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
namespace Helix;
4
5
use ErrorException;
6
use Helix\Site\Session;
7
use Helix\Site\Controller;
8
use Helix\Site\HttpError;
9
use Helix\Site\Request;
10
use Helix\Site\Response;
11
use Throwable;
12
13
/**
14
 * Routing and error handling.
15
 */
16
class Site
17
{
18
19
    /**
20
     * @var bool
21
     */
22
    protected $dev = false;
23
24
    /**
25
     * @var Request
26
     */
27
    protected $request;
28
29
    /**
30
     * @var Response
31
     */
32
    protected $response;
33
34
    /**
35
     * @var Session
36
     */
37
    protected $session;
38
39
    /**
40
     * Initializes the system for routing, error handling, and output.
41
     */
42
    public function __construct()
43
    {
44
        error_reporting(E_ALL);
45
        register_shutdown_function(fn() => $this->_onShutdown());
46
        set_error_handler(fn(...$args) => $this->_onRaise(...$args));
47
        set_exception_handler(fn(Throwable $error) => $this->_onException($error));
48
        $this->request = new Request();
49
        $this->response = new Response($this);
50
        $this->setDev(php_sapi_name() === 'cli-server');
51
    }
52
53
    /**
54
     * Handles uncaught exceptions and exits.
55
     *
56
     * @param Throwable $error
57
     * @internal
58
     */
59
    protected function _onException(Throwable $error): void
60
    {
61
        if ($error instanceof HttpError) {
62
            if ($error->getCode() >= 500) {
63
                $this->log($error->getCode(), $error);
64
            }
65
        } else {
66
            $this->log(500, "[{$error->getCode()}] {$error}");
67
        }
68
        $this->response->error_exit($error);
69
    }
70
71
    /**
72
     * Handles raised PHP errors (`E_NOTICE`, `E_WARNING`, etc) by throwing them.
73
     *
74
     * @param int $code
75
     * @param string $message
76
     * @param string $file
77
     * @param int $line
78
     * @throws ErrorException
79
     * @internal
80
     */
81
    protected function _onRaise(int $code, string $message, string $file, int $line)
82
    {
83
        error_clear_last();
84
        throw new ErrorException($message, $code, 1, $file, $line);
85
    }
86
87
    /**
88
     * A last-ditch effort to catch fatal PHP errors which aren't intercepted by {@link Site::_onRaise()}
89
     *
90
     * @internal
91
     */
92
    protected function _onShutdown(): void
93
    {
94
        if ($fatal = error_get_last()) {
95
            // can't throw, we're shutting down. call the exception handler directly.
96
            $fatal = new ErrorException($fatal['message'], $fatal['type'], 1, $fatal['file'], $fatal['line']);
97
            $this->_onException($fatal);
98
        }
99
    }
100
101
    /**
102
     * Routes `DELETE`
103
     *
104
     * @param string $path
105
     * @param string|callable $controller
106
     * @param array $extra
107
     */
108
    public function delete(string $path, $controller, array $extra = []): void
109
    {
110
        $this->route(['DELETE'], $path, $controller, $extra);
111
    }
112
113
    /**
114
     * Routes `GET` and `HEAD`
115
     *
116
     * @param string $path
117
     * @param string|callable $controller
118
     * @param array $extra
119
     */
120
    public function get(string $path, $controller, array $extra = []): void
121
    {
122
        $this->route(['GET', 'HEAD'], $path, $controller, $extra);
123
    }
124
125
    /**
126
     * @return Request
127
     */
128
    final public function getRequest()
129
    {
130
        return $this->request;
131
    }
132
133
    /**
134
     * @return Response
135
     */
136
    public function getResponse()
137
    {
138
        return $this->response;
139
    }
140
141
    /**
142
     * @return Session
143
     */
144
    final public function getSession()
145
    {
146
        return $this->session ??= new Session($this);
147
    }
148
149
    /**
150
     * @return bool
151
     */
152
    final public function isDev(): bool
153
    {
154
        return $this->dev;
155
    }
156
157
    /**
158
     * @param mixed $code
159
     * @param string $message
160
     * @return $this
161
     */
162
    public function log($code, string $message)
163
    {
164
        $now = date('Y-m-d H:i:s');
165
        $id = $this->response->getId();
166
        $ip = $this->request->getClient();
167
        $method = $this->request->getMethod();
168
        $path = $this->request->getPath();
169
        $line = "{$now} {$code} {$id} {$ip} {$method} {$path} - {$message}\n\n";
170
        error_log($line, 3, 'error.log');
171
        return $this;
172
    }
173
174
    /**
175
     * Routes `POST`
176
     *
177
     * @param string $path
178
     * @param string|callable $controller
179
     * @param array $extra
180
     */
181
    public function post(string $path, $controller, array $extra = []): void
182
    {
183
        $this->route(['POST'], $path, $controller, $extra);
184
    }
185
186
    /**
187
     * Routes `PUT`
188
     *
189
     * @param string $path
190
     * @param string|callable $controller
191
     * @param array $extra
192
     */
193
    public function put(string $path, $controller, array $extra = []): void
194
    {
195
        $this->route(['PUT'], $path, $controller, $extra);
196
    }
197
198
    /**
199
     * Invokes a controller if the HTTP method and path match, and exits.
200
     * Absolute paths must start with `/`, all other paths are treated as regular expressions.
201
     *
202
     * @param string[] $methods
203
     * @param string $path
204
     * @param string|callable $controller Controller class, or callable.
205
     * @param array $extra
206
     */
207
    public function route(array $methods, string $path, $controller, array $extra): void
208
    {
209
        $match = [];
210
        if ($path[0] !== '/') {
211
            preg_match($path, $this->request->getPath(), $match);
212
        } elseif ($path === $this->request->getPath()) {
213
            $match = [$path];
214
        }
215
        if ($match) {
216
            if (in_array($this->request->getMethod(), $methods)) {
217
                $this->response->setCode(200);
218
                $this->route_call_exit($match, $controller, $extra);
219
            }
220
            $this->response->setCode(405);
221
        }
222
    }
223
224
    /**
225
     * @param string[] $path
226
     * @param string|callable $controller
227
     * @param array $extra
228
     * @uses Controller::delete()
229
     * @uses Controller::get()
230
     * @uses Controller::post()
231
     * @uses Controller::put()
232
     * @uses Controller::__call()
233
     */
234
    protected function route_call_exit(array $path, $controller, array $extra): void
235
    {
236
        if (is_string($controller)) {
237
            assert(is_a($controller, Controller::class, true));
238
            /** @var Controller $controller */
239
            $controller = new $controller($this, $path, $extra);
240
            $method = $this->request->getMethod();
241
            $content = $controller->{$method}(); // calls are not case sensitive
242
        } else {
243
            assert(is_callable($controller));
244
            $content = call_user_func($controller, $path, $this);
245
        }
246
        $this->response->mixed_exit($content);
247
    }
248
249
    /**
250
     * @param bool $dev
251
     * @return $this
252
     */
253
    public function setDev(bool $dev)
254
    {
255
        $this->dev = $dev;
256
        return $this;
257
    }
258
}
259