RouteDefinition   B
last analyzed

Complexity

Total Complexity 43

Size/Duplication

Total Lines 372
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
eloc 127
dl 0
loc 372
ccs 137
cts 137
cp 1
rs 8.96
c 0
b 0
f 0
wmc 43

19 Methods

Rating   Name   Duplication   Size   Complexity  
A matchPatterns() 0 19 4
A addSegment() 0 18 2
A getHandler() 0 3 1
A formatUrl() 0 20 4
A createFromCache() 0 16 1
A __construct() 0 26 6
A formatEncode() 0 3 1
A appendPattern() 0 9 2
A isStatic() 0 3 1
A addMethod() 0 7 2
A getMethods() 0 3 1
A getSegments() 0 3 1
A isConstantValue() 0 13 5
A isMethodAllowed() 0 11 3
A formatPattern() 0 29 4
A hasSlash() 0 3 1
A getDefinitionCache() 0 10 1
A getName() 0 3 1
A isValidPattern() 0 16 2

How to fix   Complexity   

Complex Class

Complex classes like RouteDefinition often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RouteDefinition, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Simply\Router;
4
5
/**
6
 * Definitions for a specific route.
7
 * @author Riikka Kalliomäki <[email protected]>
8
 * @copyright Copyright (c) 2018-2019 Riikka Kalliomäki
9
 * @license http://opensource.org/licenses/mit-license.php MIT License
10
 */
11
class RouteDefinition
12
{
13
    /** Value used to indicate a segment is dynamic rather than static */
14
    public const DYNAMIC_SEGMENT = '/';
15
16
    /** @var string The name of the route */
17
    private $name;
18
19
    /** @var string[] Allowed HTTP request methods for the route */
20
    private $methods;
21
22
    /** @var string[] The static route segments */
23
    private $segments;
24
25
    /** @var string[] PCRE regular expressions for dynamic route segments */
26
    private $patterns;
27
28
    /** @var mixed The handler for the route */
29
    private $handler;
30
31
    /** @var string The format for generating the route URL from parameters */
32
    private $format;
33
34
    /** @var string[] Names of route parameters in order of appearance */
35
    private $parameterNames;
36
37
    /**
38
     * RouteDefinition constructor.
39
     * @param string $name Name of the route
40
     * @param string[] $methods Allowed HTTP request methods for the route
41
     * @param string $path Path definition for the route
42
     * @param mixed $handler Handler for route
43
     */
44 32
    public function __construct(string $name, array $methods, string $path, $handler)
45
    {
46 32
        if (!$this->isConstantValue($handler)) {
47 2
            throw new \InvalidArgumentException('Invalid route handler, expected a constant value');
48
        }
49
50 30
        $this->name = $name;
51 30
        $this->methods = [];
52 30
        $this->segments = [];
53 30
        $this->patterns = [];
54 30
        $this->handler = $handler;
55 30
        $this->format = '/';
56 30
        $this->parameterNames = [];
57
58 30
        foreach ($methods as $method) {
59 30
            $this->addMethod($method);
60
        }
61
62 29
        $segments = split_segments($path);
63
64 29
        foreach ($segments as $segment) {
65 26
            $this->addSegment($segment);
66
        }
67
68 26
        if (\count($segments) > 0 && substr($path, -1) !== '/') {
69 3
            $this->format = substr($this->format, 0, -1);
70
        }
71 26
    }
72
73
    /**
74
     * Tests if the given value is a constant value.
75
     * @param mixed $value The value to test
76
     * @return bool True if the value is a constant value, false if not
77
     */
78 32
    private function isConstantValue($value): bool
79
    {
80 32
        if (\is_array($value)) {
81 2
            foreach ($value as $item) {
82 2
                if (!$this->isConstantValue($item)) {
83 1
                    return false;
84
                }
85
            }
86
87 1
            return true;
88
        }
89
90 32
        return $value === null || is_scalar($value);
91
    }
92
93
    /**
94
     * Adds a method to the list of allowed HTTP request methods.
95
     * @param string $method The HTTP request method to add
96
     */
97 30
    private function addMethod(string $method): void
98
    {
99 30
        if (!HttpMethod::isValid($method)) {
100 1
            throw new \InvalidArgumentException("Invalid HTTP request method '$method'");
101
        }
102
103 29
        $this->methods[] = $method;
104 29
    }
105
106
    /**
107
     * Appends a path segment to the list of matched path segments for the route.
108
     * @param string $segment The segment to add
109
     */
110 26
    private function addSegment(string $segment): void
111
    {
112 26
        preg_match_all(
113 26
            "/\{(?'name'[a-z0-9_]++)(?::(?'pattern'(?:[^{}]++|\{(?&pattern)\})++))?\}/i",
114 26
            $segment,
115 26
            $matches,
116 26
            \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE | \PREG_UNMATCHED_AS_NULL
117
        );
118
119 26
        if (empty($matches)) {
120 26
            $this->segments[] = $segment;
121 26
            $this->format .= $this->formatEncode($segment) . '/';
122 26
            return;
123
        }
124
125 16
        $pattern = $this->formatPattern($segment, $matches);
126 14
        $this->patterns[\count($this->segments)] = sprintf('/%s/', $pattern);
127 14
        $this->segments[] = self::DYNAMIC_SEGMENT;
128 14
    }
129
130
    /**
131
     * Creates a dynamic segment regular expression based on the provided segment.
132
     * @param string $segment The segment to turn into regular expression
133
     * @param array[] $matches List of matches for the dynamic parts
134
     * @return string The fully formed regular expression for the segment
135
     */
136 16
    private function formatPattern(string $segment, array $matches): string
137
    {
138 16
        $fullPattern = $this->appendPattern('', $segment, 0, $matches[0][0][1]);
139
140 16
        foreach ($matches as $i => $match) {
141 16
            $name = $match['name'][0];
142 16
            $pattern = $match['pattern'][0] ?? '.*';
143
144 16
            $this->format .= '%s';
145
146 16
            if (\in_array($name, $this->parameterNames, true)) {
147 1
                throw new \InvalidArgumentException("Duplicate parameter name '$name'");
148
            }
149
150 16
            if (!$this->isValidPattern($pattern)) {
151 1
                throw new \InvalidArgumentException("Invalid regular expression '$pattern'");
152
            }
153
154 14
            $this->parameterNames[] = $name;
155 14
            $fullPattern .= sprintf("(?'%s'%s)", $name, $pattern);
156
157 14
            $start = $match[0][1] + \strlen($match[0][0]);
158 14
            $length = ($matches[$i + 1][0][1] ?? \strlen($segment)) - $start;
159 14
            $fullPattern = $this->appendPattern($fullPattern, $segment, $start, $length);
160
        }
161
162 14
        $this->format .= '/';
163
164 14
        return $fullPattern;
165
    }
166
167
    /**
168
     * Appends a static section to the pattern from the given segment.
169
     * @param string $pattern The pattern to append
170
     * @param string $segment The full segment to copy
171
     * @param int $start The start of the static section
172
     * @param int $length The length of the static section
173
     * @return string The pattern with the static section appended
174
     */
175 16
    private function appendPattern(string $pattern, string $segment, int $start, int $length): string
176
    {
177 16
        if ($length < 1) {
178 16
            return $pattern;
179
        }
180
181 2
        $constant = substr($segment, $start, $length);
182 2
        $this->format .= $this->formatEncode($constant);
183 2
        return $pattern . preg_quote($constant, '/');
184
    }
185
186
    /**
187
     * URL encodes a string to be part of the URL format string.
188
     * @param string $part The part to encode
189
     * @return string The encoded part
190
     */
191 26
    private function formatEncode(string $part): string
192
    {
193 26
        return str_replace('%', '%%', rawurlencode($part));
194
    }
195
196
    /**
197
     * Tells if the given string is a valid regular expression without delimiters.
198
     * @param string $pattern The string to test
199
     * @return bool True if it is a valid PCRE regular expression, false if not
200
     */
201 16
    private function isValidPattern(string $pattern): bool
202
    {
203
        set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
204 1
            throw new \ErrorException($message, 0, $severity, $file, $line);
205 16
        }, \E_ALL);
206
207
        try {
208 16
            $result = preg_match("/$pattern/", '');
209 1
        } catch (\ErrorException $exception) {
210 1
            $errorMessage = sprintf("Invalid regular expression '%s': %s", $pattern, $exception->getMessage());
211 1
            throw new \InvalidArgumentException($errorMessage, 0, $exception);
212 15
        } finally {
213 16
            restore_error_handler();
214
        }
215
216 15
        return $result !== false;
217
    }
218
219
    /**
220
     * Returns a new RouteDefinition instance based on the cached values.
221
     * @param array $cache The cached RouteDefinition values
222
     * @return self A new RouteDefinition instance
223
     */
224 20
    public static function createFromCache(array $cache): self
225
    {
226
        /** @var self $definition */
227 20
        $definition = (new \ReflectionClass(static::class))->newInstanceWithoutConstructor();
228
229
        [
230 20
            $definition->name,
231 20
            $definition->methods,
232 20
            $definition->segments,
233 20
            $definition->patterns,
234 20
            $definition->handler,
235 20
            $definition->format,
236 20
            $definition->parameterNames,
237 20
        ] = $cache;
238
239 20
        return $definition;
240
    }
241
242
    /**
243
     * Returns cached values for the RouteDefinition that can be used to instantiate a new RouteDefinition.
244
     * @return array RouteDefinition cache values
245
     */
246 24
    public function getDefinitionCache(): array
247
    {
248
        return [
249 24
            $this->name,
250 24
            $this->methods,
251 24
            $this->segments,
252 24
            $this->patterns,
253 24
            $this->handler,
254 24
            $this->format,
255 24
            $this->parameterNames,
256
        ];
257
    }
258
259
    /**
260
     * Returns the name of the route.
261
     * @return string The name of the route
262
     */
263 24
    public function getName(): string
264
    {
265 24
        return $this->name;
266
    }
267
268
    /**
269
     * Returns the allowed methods for the route.
270
     * @return string[] The allowed methods for the route
271
     */
272 4
    public function getMethods(): array
273
    {
274 4
        return $this->methods;
275
    }
276
277
    /**
278
     * Returns the static segments for the route.
279
     * @return string[] The static segments for the route
280
     */
281 24
    public function getSegments(): array
282
    {
283 24
        return $this->segments;
284
    }
285
286
    /**
287
     * Returns the route handler.
288
     * @return mixed The route handler
289
     */
290 15
    public function getHandler()
291
    {
292 15
        return $this->handler;
293
    }
294
295
    /**
296
     * Tells if the canonical route path ends in a forward slash or not.
297
     * @return bool True if the path ends in a slash, false if not
298
     */
299 11
    public function hasSlash(): bool
300
    {
301 11
        return substr($this->format, -1) === '/';
302
    }
303
304
    /**
305
     * Tells if the path is completely static without any dynamic segments.
306
     * @return bool True if the path is static, false if not
307
     */
308 24
    public function isStatic(): bool
309
    {
310 24
        return \count($this->parameterNames) === 0;
311
    }
312
313
    /**
314
     * Matches the given segments against the dynamic path segments.
315
     * @param string[] $segments The segments to match against
316
     * @param string[] $values Array that will be populated with route parameter values on match
317
     * @return bool True if the dynamic segments match, false if not
318
     */
319 20
    public function matchPatterns(array $segments, array & $values): bool
320
    {
321 20
        $parsed = [];
322
323 20
        foreach ($this->patterns as $i => $pattern) {
324 10
            if (!preg_match($pattern, $segments[$i], $match)) {
325 2
                return false;
326
            }
327
328 10
            if ($match[0] !== $segments[$i]) {
329 2
                return false;
330
            }
331
332 9
            $parsed += array_intersect_key($match, array_flip($this->parameterNames));
333
        }
334
335 19
        $values = $parsed;
336
337 19
        return true;
338
    }
339
340
    /**
341
     * Tells if the given HTTP request method is allowed by the route.
342
     * @param string $method The HTTP request method to test
343
     * @return bool True if the given HTTP request method is allowed, false if not
344
     */
345 19
    public function isMethodAllowed(string $method): bool
346
    {
347 19
        if (\in_array($method, $this->methods, true)) {
348 16
            return true;
349
        }
350
351 5
        if ($method === HttpMethod::HEAD) {
352 1
            return \in_array(HttpMethod::GET, $this->methods, true);
353
        }
354
355 4
        return false;
356
    }
357
358
    /**
359
     * Returns an encoded URL for the route based on the given parameter values.
360
     * @param string[] $parameters Values for the route parameters
361
     * @return string The encoded URL for the route
362
     */
363 15
    public function formatUrl(array $parameters = []): string
364
    {
365 15
        $values = [];
366
367 15
        foreach ($this->parameterNames as $name) {
368 9
            if (!isset($parameters[$name])) {
369 1
                throw new \InvalidArgumentException("Missing route parameter '$name'");
370
            }
371
372 8
            $values[] = rawurlencode($parameters[$name]);
373 8
            unset($parameters[$name]);
374
        }
375
376 14
        if (!empty($parameters)) {
377 1
            throw new \InvalidArgumentException(
378 1
                'Unexpected route parameters: ' . implode(', ', array_keys($parameters))
379
            );
380
        }
381
382 13
        return vsprintf($this->format, $values);
383
    }
384
}
385