Completed
Push — master ( 7eca5e...1e154b )
by Anton
02:50
created

AbstractRoute::__invoke()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 2
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
9
namespace Spiral\Http\Routing;
10
11
use Psr\Http\Message\ResponseInterface as Response;
12
use Psr\Http\Message\ServerRequestInterface as Request;
13
use Psr\Http\Message\UriInterface;
14
use Spiral\Core\ContainerInterface;
15
use Spiral\Http\Exceptions\RouteException;
16
use Spiral\Http\MiddlewareInterface;
17
use Spiral\Http\MiddlewarePipeline;
18
use Spiral\Http\Uri;
19
use Spiral\Support\Strings;
20
21
/**
22
 * Abstract route with ability to execute endpoint using middleware pipeline and context container.
23
 *
24
 * Attention, route does not extends container is it's mandatory to be set.
25
 */
26
abstract class AbstractRoute implements RouteInterface
27
{
28
    /**
29
     * Default segment pattern, this patter can be applied to controller names, actions and etc.
30
     */
31
    const DEFAULT_SEGMENT = '[^\/]+';
32
33
    /**
34
     * @var string
35
     */
36
    private $name = '';
37
38
    /**
39
     * Path prefix (base path).
40
     *
41
     * @var string
42
     */
43
    private $prefix = '';
44
45
    /**
46
     * Default set of values to fill route matches and target pattern (if specified as pattern).
47
     *
48
     * @var array
49
     */
50
    private $defaults = [];
51
52
    /**
53
     * If true route will be matched with URI host in addition to path. BasePath will be ignored.
54
     *
55
     * @var bool
56
     */
57
    private $withHost = false;
58
59
    /**
60
     * Compiled route options, pattern and etc. Internal data.
61
     *
62
     * @invisible
63
     * @var array
64
     */
65
    private $compiled = [];
66
67
    /**
68
     * Route matches, populated after match() method executed. Internal.
69
     *
70
     * @var array
71
     */
72
    protected $matches = [];
73
74
    /**
75
     * Route pattern includes simplified regular expressing later compiled to real regexp.
76
     *
77
     * @var string
78
     */
79
    protected $pattern = '';
80
81
    /**
82
     * @var array
83
     */
84
    protected $middlewares = [];
85
86
    /**
87
     * Route endpoint container context.
88
     *
89
     * @invisible
90
     * @var ContainerInterface|null
91
     */
92
    protected $container = null;
93
94
    /**
95
     * @param string $name
96
     * @param array  $defaults
97
     */
98
    public function __construct(string $name, array $defaults)
99
    {
100
        $this->name = $name;
101
        $this->defaults = $defaults;
102
    }
103
104
    /**
105
     * {@inheritdoc}
106
     *
107
     * @return $this|AbstractRoute
108
     */
109
    public function withContainer(ContainerInterface $container): RouteInterface
110
    {
111
        $route = clone $this;
112
        $route->container = $container;
113
114
        return $route;
115
    }
116
117
    /**
118
     * {@inheritdoc}
119
     *
120
     * @return $this|AbstractRoute
121
     */
122
    public function withName(string $name): RouteInterface
123
    {
124
        $route = clone $this;
125
        $route->name = $name;
126
127
        return $route;
128
    }
129
130
    /**
131
     * {@inheritdoc}
132
     */
133
    public function getName(): string
134
    {
135
        return $this->name;
136
    }
137
138
    /**
139
     * {@inheritdoc}
140
     *
141
     * @return $this|AbstractRoute
142
     */
143
    public function withPrefix(string $prefix): RouteInterface
144
    {
145
        $route = clone $this;
146
        $route->prefix = rtrim($prefix, '/') . '/';
147
148
        return $route;
149
    }
150
151
    /**
152
     * {@inheritdoc}
153
     */
154
    public function getPrefix(): string
155
    {
156
        return $this->prefix;
157
    }
158
159
    /**
160
     * If true (default) route will be matched against path + URI host. Returns new route instance.
161
     *
162
     * @param bool $withHost
163
     *
164
     * @return $this|AbstractRoute
165
     */
166
    public function withHost(bool $withHost = true): AbstractRoute
167
    {
168
        $route = clone $this;
169
        $route->withHost = $withHost;
170
171
        return $route;
172
    }
173
174
    /**
175
     * {@inheritdoc}
176
     *
177
     * @return $this|AbstractRoute
178
     */
179
    public function withDefaults(array $defaults): RouteInterface
180
    {
181
        $copy = clone $this;
182
        $copy->defaults = $defaults;
183
184
        return $copy;
185
    }
186
187
    /**
188
     * Get default route values.
189
     *
190
     * @return array
191
     */
192
    public function getDefaults(): array
193
    {
194
        return $this->defaults;
195
    }
196
197
    /**
198
     * Associated middleware with route. New instance of route will be returned.
199
     *
200
     * Example:
201
     * $route->withMiddleware(new CacheMiddleware(100));
202
     * $route->withMiddleware(ProxyMiddleware::class);
203
     * $route->withMiddleware([ProxyMiddleware::class, OtherMiddleware::class]);
204
     *
205
     * @param callable|MiddlewareInterface|array $middleware
206
     *
207
     * @return $this|AbstractRoute
208
     */
209
    public function withMiddleware($middleware): AbstractRoute
210
    {
211
        $route = clone $this;
212
        if (is_array($middleware)) {
213
            $route->middlewares = array_merge($route->middlewares, $middleware);
214
        } else {
215
            $route->middlewares[] = $middleware;
216
        }
217
218
        return $route;
219
    }
220
221
    /**
222
     * {@inheritdoc}
223
     */
224
    public function match(Request $request)
225
    {
226
        if (empty($this->compiled)) {
227
            $this->compile();
228
        }
229
230
        if (preg_match($this->compiled['pattern'], $this->getSubject($request), $matches)) {
231
            //To get only named matches
232
            $matches = array_intersect_key($matches, $this->compiled['options']);
233
            $matches = array_merge($this->compiled['options'], $this->defaults, $matches);
234
235
            return $this->withMatches($matches);
236
        }
237
238
        return null;
239
    }
240
241
    /**
242
     * {@inheritdoc}
243
     */
244
    public function __invoke(Request $request, Response $response): Response
245
    {
246
        if (empty($this->container)) {
247
            throw new RouteException("Unable to perform route endpoint without given container");
248
        }
249
250
        $pipeline = new MiddlewarePipeline($this->middlewares, $this->container);
251
252
        return $pipeline->target($this->createEndpoint())->run($request, $response);
253
    }
254
255
    /**
256
     * {@inheritdoc}
257
     */
258
    public function uri($parameters = []): UriInterface
259
    {
260
        if (empty($this->compiled)) {
261
            $this->compile();
262
        }
263
264
        $parameters = array_merge(
265
            $this->compiled['options'],
266
            $this->defaults,
267
            $this->matches,
268
            $this->fetchSegments($parameters, $query)
269
        );
270
271
        //Uri without empty blocks (pretty stupid implementation)
272
        $path = strtr(
273
            \Spiral\interpolate($this->compiled['template'], $parameters, '<', '>'),
274
            ['[]' => '', '[/]' => '', '[' => '', ']' => '', '://' => '://', '//' => '/']
275
        );
276
277
        //Uri with added prefix
278
        $uri = new Uri(($this->withHost ? '' : $this->prefix) . trim($path, '/'));
279
280
        return empty($query) ? $uri : $uri->withQuery(http_build_query($query));
281
    }
282
283
    /**
284
     * Route matches.
285
     *
286
     * @return array
287
     */
288
    public function getMatches(): array
289
    {
290
        return $this->matches;
291
    }
292
293
    /**
294
     * @param string $name
295
     * @param mixed  $default
296
     *
297
     * @return mixed
298
     */
299
    public function getMatch(string $name, $default = null)
300
    {
301
        if (array_key_exists($name, $this->matches)) {
302
            return $this->matches[$name];
303
        }
304
305
        return $default;
306
    }
307
308
    /**
309
     * @param array $matches
310
     *
311
     * @return self|AbstractRoute
312
     */
313
    protected function withMatches(array $matches): AbstractRoute
314
    {
315
        $route = clone $this;
316
        $route->matches = $matches;
317
318
        return $route;
319
    }
320
321
    /**
322
     * Fetch uri segments and query parameters.
323
     *
324
     * @param \Traversable|array $parameters
325
     * @param array|null         $query Query parameters.
326
     *
327
     * @return array
328
     */
329
    protected function fetchSegments($parameters, &$query): array
330
    {
331
        $allowed = array_keys($this->compiled['options']);
332
333
        $result = [];
334
        foreach ($parameters as $key => $parameter) {
335
            //This segment fetched keys from given parameters either by name or by position
336
            if (is_numeric($key) && isset($allowed[$key])) {
337
                $key = $allowed[$key];
338
            } elseif (
339
                !array_key_exists($key, $this->compiled['options'])
340
                && is_array($parameters)
341
            ) {
342
                $query[$key] = $parameter;
343
                continue;
344
            }
345
346
            //String must be normalized here
347
            if (is_string($parameter) && !preg_match('/^[a-z\-_0-9]+$/i', $parameter)) {
348
                $result[$key] = Strings::slug($parameter);
349
                continue;
350
            }
351
352
            $result[$key] = (string)$parameter;
353
        }
354
355
        return $result;
356
    }
357
358
    /**
359
     * Create callable route endpoint.
360
     *
361
     * @return callable
362
     */
363
    abstract protected function createEndpoint();
364
365
    /**
366
     * {@inheritdoc}
367
     */
368
    protected function iocContainer(): ContainerInterface
369
    {
370
        if (empty($this->container)) {
371
            throw new RouteException("Route context container has not been set");
372
        }
373
374
        return $this->container;
375
    }
376
377
    /**
378
     * Compile router pattern into valid regexp.
379
     */
380
    private function compile()
381
    {
382
        $replaces = ['/' => '\\/', '[' => '(?:', ']' => ')?', '.' => '\.'];
383
384
        $options = [];
385
        if (preg_match_all('/<(\w+):?(.*?)?>/', $this->pattern, $matches)) {
386
            $variables = array_combine($matches[1], $matches[2]);
387
388
            foreach ($variables as $name => $segment) {
389
                //Segment regex
390
                $segment = !empty($segment) ? $segment : self::DEFAULT_SEGMENT;
391
                $replaces["<$name>"] = "(?P<$name>$segment)";
392
                $options[] = $name;
393
            }
394
        }
395
396
        $template = preg_replace('/<(\w+):?.*?>/', '<\1>', $this->pattern);
397
398
        $this->compiled = [
399
            'pattern'  => '/^' . strtr($template, $replaces) . '$/iu',
400
            'template' => stripslashes(str_replace('?', '', $template)),
401
            'options'  => array_fill_keys($options, null)
402
        ];
403
    }
404
405
    /**
406
     * Part of uri path which is being matched.
407
     *
408
     * @param Request $request
409
     *
410
     * @return string
411
     */
412
    private function getSubject(Request $request): string
413
    {
414
        $path = $request->getUri()->getPath();
415
416
        if (empty($path) || $path[0] !== '/') {
417
            $path = '/' . $path;
418
        }
419
420
        if ($this->withHost) {
421
            $uri = $request->getUri()->getHost() . $path;
422
        } else {
423
            $uri = substr($path, strlen($this->prefix));
424
        }
425
426
        return trim($uri, '/');
427
    }
428
}
429