Completed
Push — master ( 73e57b...d402f5 )
by Anton
08:01
created

AbstractRoute::withHost()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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