Test Failed
Pull Request — master (#16)
by Divine Niiquaye
02:58
created

RouteMatcher::interpolate()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 4
nc 5
nop 2
dl 0
loc 9
rs 10
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.1 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Flight\Routing;
19
20
use Flight\Routing\Exceptions\{MethodNotAllowedException, UriHandlerException, UrlGenerationException};
21
use Flight\Routing\Interfaces\{RouteCompilerInterface, RouteMatcherInterface};
22
use Psr\Http\Message\{ServerRequestInterface, UriInterface};
23
24
/**
25
 * The bidirectional route matcher responsible for matching
26
 * HTTP request and generating url from routes.
27
 *
28
 * @author Divine Niiquaye Ibok <[email protected]>
29
 */
30
class RouteMatcher implements RouteMatcherInterface
31
{
32
    private const URI_FIXERS = [
33
        '[]' => '',
34
        '[/]' => '',
35
        '[' => '',
36
        ']' => '',
37
        '://' => '://',
38
        '//' => '/',
39
    ];
40
41
    /** @var \Iterator<int,Route>|\Iterator<int,array> */
42
    protected $routes = [];
43
44
    /** @var Matchers\SimpleRouteDumper|array|null */
45
    private $dumper = null;
46
47
    /** @var RouteCompilerInterface */
48
    private $compiler;
49
50
    public function __construct(\Iterator $collection, ?RouteCompilerInterface $compiler = null, string $cacheFile = null)
51
    {
52
        $this->compiler = $compiler ?? new Matchers\SimpleRouteCompiler();
53
        $this->routes = $collection;
54
55
        if (!empty($cacheFile)) {
56
            if (\file_exists($cacheFile)) {
57
                $cachedRoutes = require $cacheFile;
58
59
                $this->routes = new \ArrayIterator($cachedRoutes[3]);
60
                unset($cachedRoutes[3]);
61
            }
62
63
            $this->dumper = $cachedRoutes ?? new Matchers\SimpleRouteDumper($cacheFile);
64
        }
65
    }
66
67
    /**
68
     * {@inheritdoc}
69
     */
70
    public function match(ServerRequestInterface $request): ?Route
71
    {
72
        $requestUri = $request->getUri();
73
74
        // Resolve request path to match sub-directory or /index.php/path
75
        if (empty($resolvedPath = $request->getServerParams()['PATH_INFO'] ?? '')) {
76
            $resolvedPath = $requestUri->getPath();
77
        }
78
79
        if ('/' !== $resolvedPath && isset(Route::URL_PREFIX_SLASHES[$resolvedPath[-1]])) {
80
            $resolvedPath = \substr($resolvedPath, 0, -1);
81
        }
82
83
        [$matchedRoute, $matchedDomains, $variables] = $this->matchRoute($resolvedPath = \rawurldecode($resolvedPath));
84
85
        if ($matchedRoute instanceof Route) {
86
            $schemes = $matchedRoute->get('schemes');
87
88
            if (!\array_key_exists($request->getMethod(), $matchedRoute->get('methods'))) {
89
                throw new MethodNotAllowedException(\array_keys($matchedRoute->get('methods')), $requestUri->getPath(), $request->getMethod());
90
            }
91
92
            if (!empty($schemes) && !\array_key_exists($requestUri->getScheme(), $schemes)) {
93
                throw new UriHandlerException(\sprintf('Unfortunately current scheme "%s" is not allowed on requested uri [%s]', $requestUri->getScheme(), $resolvedPath), 400);
94
            }
95
96
            if (!empty($matchedDomains)) {
97
                if (null === $hostVars = $this->compareDomain($matchedDomains, $requestUri)) {
98
                    throw new UriHandlerException(\sprintf('Unfortunately current domain "%s" is not allowed on requested uri [%s]', $requestUri->getHost(), $resolvedPath), 400);
99
                }
100
101
                $variables = \array_replace($variables, $hostVars);
102
            }
103
104
            foreach ($variables as $key => $value) {
105
                if (\is_int($key)) {
106
                    continue;
107
                }
108
109
                $matchedRoute->argument($key, $value);
110
            }
111
        }
112
113
        return $matchedRoute;
114
    }
115
116
    /**
117
     * {@inheritdoc}
118
     *
119
     * @return string of fully qualified URL for named route
120
     */
121
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): string
122
    {
123
        foreach ($this->routes as $route) {
124
            if (!$route instanceof Route) {
125
                $route = Route::__set_state($route);
126
            }
127
128
            if ($routeName === $route->get('name')) {
129
                $compiledRoute = $this->isCompiled() ? \unserialize($this->dumper[2][$routeName]) : $this->compiler->compile($route, true);
130
                $uriRoute = $this->buildPath($route, $compiledRoute, $parameters);
131
132
                // Incase query is added to uri.
133
                if ([] !== $queryParams) {
134
                    $uriRoute .= '?' . \http_build_query($queryParams);
135
                }
136
137
                if (!\str_contains($uriRoute, '://')) {
138
                    $prefix = '.'; // Append missing "." at the beginning of the $uri.
139
140
                    if ('/' !== @$uriRoute[0]) {
141
                        $prefix .= '/';
142
                    }
143
144
                    $uriRoute = $prefix . $uriRoute;
145
                }
146
147
                return $uriRoute;
148
            }
149
        }
150
151
        throw new UrlGenerationException(\sprintf('Unable to generate a URL for the named route "%s" as such route does not exist.', $routeName), 404);
152
    }
153
154
    public function getCompiler(): RouteCompilerInterface
155
    {
156
        return $this->compiler;
157
    }
158
159
    /**
160
     * Return true if routes are compiled.
161
     */
162
    public function isCompiled(): bool
163
    {
164
        return \is_array($this->dumper);
165
    }
166
167
    /**
168
     * This method is used to build uri, this can be overwritten.
169
     *
170
     * @param array<int|string,int|string> $parameters
171
     */
172
    protected function buildPath(Route $route, CompiledRoute $compiledRoute, array $parameters): string
173
    {
174
        $path = $host = '';
175
176
        // Fetch and merge all possible parameters + variables keys + route defaults ...
177
        $parameters = $this->fetchOptions($parameters, \array_keys($compiledRoute->getVariables()));
178
        $parameters = $parameters + $route->get('defaults') + $compiledRoute->getVariables();
179
180
        if (1 === \count($hostRegexs = $compiledRoute->getHostsRegex())) {
0 ignored issues
show
Bug introduced by
It seems like $hostRegexs = $compiledRoute->getHostsRegex() can also be of type string; however, parameter $value of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

180
        if (1 === \count(/** @scrutinizer ignore-type */ $hostRegexs = $compiledRoute->getHostsRegex())) {
Loading history...
181
            $host = $hostRegexs[0];
182
        }
183
184
        if (!empty($schemes = $route->get('schemes'))) {
185
            $schemes = [isset($_SERVER['HTTPS']) ? 'https' : 'http' => true];
186
187
            if (empty($host)) {
188
                $host = $_SERVER['HTTP_HOST'] ?? '';
189
            }
190
        }
191
192
        if (!empty($host)) {
193
            // If we have s secured scheme, it should be served
194
            $hostScheme = isset($schemes['https']) ? 'https' : (\array_key_last($schemes) ?? 'http');
195
            $path = "{$hostScheme}://" . \trim($this->interpolate($host, $parameters), '.');
196
        }
197
198
        return $path .= $this->interpolate($compiledRoute->getRegex(), $parameters);
199
    }
200
201
    /**
202
     * Match Route based on HTTP request path.
203
     */
204
    protected function matchRoute(string $resolvedPath): array
205
    {
206
        if (\is_array($dumper = $this->dumper)) {
207
            [$staticRoutes, $regexpList] = $dumper;
208
209
            if ([null, [], []] !== $matchedRoute = $staticRoutes[$resolvedPath] ?? [null, [], []]) {
210
                $route = $this->routes[$matchedRoute[0]];
211
                $matchedRoute[0] = $route instanceof Route ? $route : Route::__set_state($route);
212
213
                return $matchedRoute;
214
            }
215
216
            if (1 === \preg_match($regexpList[0], $resolvedPath, $urlVariables)) {
217
                $route = $this->routes[$routeId = $urlVariables['MARK']];
218
                [$matchedDomains, $variables, $varKeys] = $regexpList[1][$routeId];
219
220
                foreach ($varKeys as $index => $key) {
221
                    $variables[$key] = $urlVariables[$index] ?? null;
222
                }
223
224
                return [$route instanceof Route ? $route : Route::__set_state($route), $matchedDomains, $variables];
225
            }
226
227
            return $matchedRoute;
228
        }
229
230
        foreach ($this->routes as $route) {
231
            $compiledRoute = $this->compiler->compile($route);
232
233
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
234
            if ($resolvedPath === $compiledRoute->getStatic() || 1 === \preg_match($compiledRoute->getRegex(), $resolvedPath, $uriVars)) {
235
                try {
236
                    return [$route, $compiledRoute->getHostsRegex(), ($uriVars ?? []) + $compiledRoute->getVariables()];
237
                } finally {
238
                    if ($dumper instanceof Matchers\SimpleRouteDumper) {
239
                        $dumper->dump($this->routes, $this->compiler);
240
                    }
241
                }
242
            }
243
        }
244
245
        return [null, [], []];
246
    }
247
248
    /**
249
     * Interpolate string with given values.
250
     *
251
     * @param array<int|string,mixed> $values
252
     */
253
    private function interpolate(string $string, array $values): string
254
    {
255
        $replaces = self::URI_FIXERS;
256
257
        foreach ($values as $key => $value) {
258
            $replaces["<{$key}>"] = (\is_array($value) || $value instanceof \Closure) ? '' : $value;
259
        }
260
261
        return \strtr($string, $replaces);
262
    }
263
264
    /**
265
     * Fetch uri segments and query parameters.
266
     *
267
     * @param array<int|string,mixed> $parameters
268
     * @param array<int|string,mixed> $allowed
269
     *
270
     * @return array<int|string,mixed>
271
     */
272
    private function fetchOptions($parameters, array $allowed): array
273
    {
274
        $result = [];
275
276
        foreach ($parameters as $key => $parameter) {
277
            if (\is_numeric($key) && isset($allowed[$key])) {
278
                // this segment fetched keys from given parameters either by name or by position
279
                $key = $allowed[$key];
280
            }
281
282
            // TODO: String must be normalized here
283
            $result[$key] = $parameter;
284
        }
285
286
        return $result;
287
    }
288
289
    /**
290
     * Check if given request domain matches given route domain.
291
     *
292
     * @param string|string[] $routeDomains
293
     *
294
     * @return array<int|string,mixed>|null
295
     */
296
    protected function compareDomain($routeDomains, UriInterface $requestUri): ?array
297
    {
298
        $hostAndPort = $requestUri->getHost();
299
300
        // Added port to host for matching ...
301
        if (null !== $requestUri->getPort()) {
302
            $hostAndPort .= ':' . $requestUri->getPort();
303
        }
304
305
        if (\is_string($routeDomains)) {
306
            return 1 === \preg_match($routeDomains, $hostAndPort, $parameters) ? $parameters : null;
307
        }
308
309
        foreach ($routeDomains as $routeDomain) {
310
            if (1 === \preg_match($routeDomain, $hostAndPort, $parameters)) {
311
                return $parameters;
312
            }
313
        }
314
315
        return null;
316
    }
317
}
318