Issues (120)

src/RouteCollection.php (2 issues)

1
<?php declare(strict_types=1);
2
3
/*
4
 * This file is part of Flight Routing.
5
 *
6
 * PHP version 8.0 and above required
7
 *
8
 * @author    Divine Niiquaye Ibok <[email protected]>
9
 * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/)
10
 * @license   https://opensource.org/licenses/BSD-3-Clause License
11
 *
12
 * For the full copyright and license information, please view the LICENSE
13
 * file that was distributed with this source code.
14
 */
15
16
namespace Flight\Routing;
17
18
/**
19
 * A RouteCollection represents a set of Route instances.
20
 *
21
 * This class provides all(*) methods for creating path+HTTP method-based routes and
22
 * injecting them into the router:
23
 *
24
 * - get
25
 * - post
26
 * - put
27
 * - patch
28
 * - delete
29
 * - options
30
 * - any
31
 * - resource
32
 *
33
 * A general `add()` method allows specifying multiple request methods and/or
34
 * arbitrary request methods when creating a path-based route.
35
 *
36
 * @author Divine Niiquaye Ibok <[email protected]>
37
 */
38
class RouteCollection implements \Countable, \ArrayAccess
39
{
40
    use Traits\PrototypeTrait;
41
42
    /**
43
     * A Pattern to Locates appropriate route by name, support dynamic route allocation using following pattern:
44
     * Pattern route:   `pattern/*<controller@action>`
45
     * Default route: `*<controller@action>`
46
     * Only action:   `pattern/*<action>`.
47
     */
48
    public const RCA_PATTERN = '/^(?:([a-z]+)\:)?(?:\/{2}([^\/]+))?([^*]*)(?:\*\<(?:([\w+\\\\]+)\@)?(\w+)\>)?$/u';
49
50
    /**
51
     * A Pattern to match the route's priority.
52
     *
53
     * If route path matches, 1 is expected return else 0 should be return as priority index.
54
     */
55
    protected const PRIORITY_REGEX = '/([^<[{:]+\b)/A';
56
57
    protected ?self $parent = null;
58
    protected ?string $namedPrefix = null;
59
60
    /**
61
     * @internal
62
     *
63
     * @param array<string,mixed> $properties
64
     */
65 2
    public static function __set_state(array $properties): static
66
    {
67 2
        $collection = new static();
68
69 2
        foreach ($properties as $property => $value) {
70 2
            $collection->{$property} = $value;
71
        }
72
73 2
        return $collection;
74
    }
75
76
    /**
77
     * Sort all routes beginning with static routes.
78
     */
79 8
    public function sort(): void
80
    {
81 8
        if (!empty($this->groups)) {
82 6
            $this->injectGroups('', $this->routes, $this->defaultIndex);
83
        }
84
85 8
        $this->sorted || $this->sorted = \usort($this->routes, static function (array $a, array $b): int {
86 8
            $ap = $a['prefix'] ?? null;
87 8
            $bp = $b['prefix'] ?? null;
88
89 8
            return !($ap && $ap === $a['path']) <=> !($bp && $bp === $b['path']) ?: \strnatcmp($a['path'], $b['path']);
90
        });
91
    }
92
93
    /**
94
     * Get all the routes.
95
     *
96
     * @return array<int,array<string,mixed>>
97
     */
98 100
    public function getRoutes(): array
99
    {
100 100
        if (!empty($this->groups)) {
101 6
            $this->injectGroups('', $this->routes, $this->defaultIndex);
102
        }
103
104 100
        return $this->routes;
105
    }
106
107
    /**
108
     * Get the total number of routes.
109
     */
110 11
    public function count(): int
111
    {
112 11
        if (!empty($this->groups)) {
113 4
            $this->injectGroups('', $this->routes, $this->defaultIndex);
114
        }
115
116 11
        return $this->defaultIndex + 1;
117
    }
118
119
    /**
120
     * Checks if route by its index exists.
121
     */
122 5
    public function offsetExists(mixed $offset): bool
123
    {
124 5
        return isset($this->routes[$offset]);
125
    }
126
127
    /**
128
     * Get the route by its index.
129
     *
130
     * @return null|array<string,mixed>
131
     */
132 10
    public function offsetGet(mixed $offset): ?array
133
    {
134 10
        return $this->routes[$offset] ?? null;
135
    }
136
137 1
    public function offsetUnset(mixed $offset): void
138
    {
139 1
        unset($this->routes[$offset]);
140
    }
141
142 1
    public function offsetSet(mixed $offset, mixed $value): void
143
    {
144 1
        throw new \BadMethodCallException('The operator "[]" for new route, use the add() method instead.');
145
    }
146
147
    /**
148
     * Maps a pattern to a handler.
149
     *
150
     * You can must specify HTTP methods that should be matched.
151
     *
152
     * @param string   $pattern Matched route pattern
153
     * @param string[] $methods Matched HTTP methods
154
     * @param mixed    $handler Handler that returns the response when matched
155
     *
156
     * @return $this
157
     */
158 107
    public function add(string $pattern, array $methods = Router::DEFAULT_METHODS, mixed $handler = null): self
159
    {
160 107
        $this->asRoute = true;
161 107
        $this->routes[++$this->defaultIndex] = ['handler' => $handler];
162 107
        $this->path($pattern);
163
164 106
        foreach ($this->prototypes as $route => $arguments) {
165 9
            if ('prefix' === $route) {
166 2
                $this->prefix(\implode('', $arguments));
167 9
            } elseif ('domain' === $route) {
168 8
                $this->domain(...$arguments);
169 9
            } elseif ('namespace' === $route) {
170 1
                foreach ($arguments as $namespace) {
171 1
                    $this->namespace($namespace);
172
                }
173
            } else {
174 9
                $this->routes[$this->defaultIndex][$route] = $arguments;
175
            }
176
        }
177
178 106
        foreach ($methods as $method) {
179 106
            $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true;
180
        }
181
182 106
        return $this;
183
    }
184
185
    /**
186
     * Mounts controllers under the given route prefix.
187
     *
188
     * @param null|string                   $name       The route group prefixed name
189
     * @param null|callable|RouteCollection $collection A RouteCollection instance or a callable for defining routes
190
     * @param bool                          $return     If true returns a new collection instance else returns $this
191
     */
192 12
    public function group(string $name = null, callable|self $collection = null, bool $return = false): self
193
    {
194 12
        $this->asRoute = false;
195
196 12
        if (\is_callable($collection)) {
197 2
            $collection($routes = $this->injectGroup($name, new static()), $return);
198
        }
199 12
        $route = $routes ?? $this->injectGroup($name, $collection ?? new static(), $return);
0 ignored issues
show
It seems like $collection ?? new static() can also be of type callable and null; however, parameter $controllers of Flight\Routing\RouteCollection::injectGroup() does only seem to accept Flight\Routing\RouteCollection, 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

199
        $route = $routes ?? $this->injectGroup($name, /** @scrutinizer ignore-type */ $collection ?? new static(), $return);
Loading history...
200 12
        $this->groups[] = $route;
201
202 12
        return $return ? $route : $this;
203
    }
204
205
    /**
206
     * Merge a collection into base.
207
     *
208
     * @return $this
209
     */
210 7
    public function populate(self $collection, bool $asGroup = false)
211
    {
212 7
        if ($asGroup) {
213 4
            $this->groups[] = $this->injectGroup($collection->namedPrefix, $collection);
214
        } else {
215 5
            $routes = $collection->routes;
216 5
            $asRoute = $this->asRoute;
217
218 5
            if (!empty($collection->groups)) {
219 1
                $collection->injectGroups($collection->namedPrefix ?? '', $routes, $this->defaultIndex);
220
            }
221
222 5
            foreach ($routes as $route) {
223 4
                $this->add($route['path'], [], $route['handler']);
224 4
                $this->routes[$this->defaultIndex] = \array_merge_recursive(
225 4
                    $this->routes[$this->defaultIndex],
226 4
                    \array_diff_key($route, ['path' => null, 'handler' => null, 'prefix' => null])
227
                );
228
            }
229 5
            $this->asRoute = $asRoute;
230
        }
231
232 7
        return $this;
233
    }
234
235
    /**
236
     * Maps a GET and HEAD request to a handler.
237
     *
238
     * @param string $pattern Matched route pattern
239
     * @param mixed  $handler Handler that returns the response when matched
240
     *
241
     * @return $this
242
     */
243 73
    public function get(string $pattern, $handler = null): self
244
    {
245 73
        return $this->add($pattern, [Router::METHOD_GET, Router::METHOD_HEAD], $handler);
246
    }
247
248
    /**
249
     * Maps a POST request to a handler.
250
     *
251
     * @param string $pattern Matched route pattern
252
     * @param mixed  $handler Handler that returns the response when matched
253
     *
254
     * @return $this
255
     */
256 2
    public function post(string $pattern, $handler = null): self
257
    {
258 2
        return $this->add($pattern, [Router::METHOD_POST], $handler);
259
    }
260
261
    /**
262
     * Maps a PUT request to a handler.
263
     *
264
     * @param string $pattern Matched route pattern
265
     * @param mixed  $handler Handler that returns the response when matched
266
     *
267
     * @return $this
268
     */
269 1
    public function put(string $pattern, $handler = null): self
270
    {
271 1
        return $this->add($pattern, [Router::METHOD_PUT], $handler);
272
    }
273
274
    /**
275
     * Maps a PATCH request to a handler.
276
     *
277
     * @param string $pattern Matched route pattern
278
     * @param mixed  $handler Handler that returns the response when matched
279
     *
280
     * @return $this
281
     */
282 2
    public function patch(string $pattern, $handler = null): self
283
    {
284 2
        return $this->add($pattern, [Router::METHOD_PATCH], $handler);
285
    }
286
287
    /**
288
     * Maps a DELETE request to a handler.
289
     *
290
     * @param string $pattern Matched route pattern
291
     * @param mixed  $handler Handler that returns the response when matched
292
     *
293
     * @return $this
294
     */
295 1
    public function delete(string $pattern, $handler = null): self
296
    {
297 1
        return $this->add($pattern, [Router::METHOD_DELETE], $handler);
298
    }
299
300
    /**
301
     * Maps a OPTIONS request to a handler.
302
     *
303
     * @param string $pattern Matched route pattern
304
     * @param mixed  $handler Handler that returns the response when matched
305
     *
306
     * @return $this
307
     */
308 1
    public function options(string $pattern, $handler = null): self
309
    {
310 1
        return $this->add($pattern, [Router::METHOD_OPTIONS], $handler);
311
    }
312
313
    /**
314
     * Maps any request to a handler.
315
     *
316
     * @param string $pattern Matched route pattern
317
     * @param mixed  $handler Handler that returns the response when matched
318
     *
319
     * @return $this
320
     */
321 1
    public function any(string $pattern, $handler = null): self
322
    {
323 1
        return $this->add($pattern, Router::HTTP_METHODS_STANDARD, $handler);
324
    }
325
326
    /**
327
     * Maps any Router::HTTP_METHODS_STANDARD request to a resource handler prefixed to $action's method name.
328
     *
329
     * E.g: Having pattern as "/accounts/{userId}", all request made from supported request methods
330
     * are to have the same url.
331
     *
332
     * @param string              $action   The prefixed name attached to request method
333
     * @param string              $pattern  matched path where request should be sent to
334
     * @param class-string|object $resource Handler that returns the response
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string|object at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string|object.
Loading history...
335
     *
336
     * @return $this
337
     */
338 1
    public function resource(string $pattern, string|object $resource, string $action = 'action'): self
339
    {
340 1
        return $this->any($pattern, new Handlers\ResourceHandler($resource, $action));
341
    }
342
343 14
    public function generateRouteName(string $prefix, array $route = null): string
344
    {
345 14
        $route = $route ?? $this->routes[$this->defaultIndex];
346 14
        $routeName = \implode('_', \array_keys($route['methods'] ?? [])).'_'.$prefix.$route['path'] ?? '';
347 14
        $routeName = \str_replace(['/', ':', '|', '-'], '_', $routeName);
348 14
        $routeName = (string) \preg_replace('/[^a-z0-9A-Z_.]+/', '', $routeName);
349
350 14
        return (string) \preg_replace(['/\_+/', '/\.+/'], ['_', '.'], $routeName);
351
    }
352
353
    // 'next', 'key', 'valid', 'rewind'
354
355 15
    protected function injectGroup(?string $prefix, self $controllers, bool $return = false): self
356
    {
357 15
        $controllers->prototypes = \array_merge_recursive($this->prototypes, $controllers->prototypes);
358
359 15
        if ($return) {
360 4
            $controllers->parent = $this;
361
        }
362
363 15
        if (empty($controllers->namedPrefix)) {
364 15
            $controllers->namedPrefix = $prefix;
365
        }
366
367 15
        return $controllers;
368
    }
369
370
    /**
371
     * @param array<int,array<string,mixed>> $collection
372
     */
373 15
    private function injectGroups(string $prefix, array &$collection, int &$count): void
374
    {
375 15
        $unnamedRoutes = [];
376
377 15
        foreach ($this->groups as $group) {
378 15
            foreach ($group->routes as $route) {
379 14
                if (empty($name = $route['name'] ?? '')) {
380 14
                    $name = $group->generateRouteName('', $route);
381
382 14
                    if (isset($unnamedRoutes[$name])) {
383 3
                        $name .= ('_' !== $name[-1] ? '_' : '').++$unnamedRoutes[$name];
384
                    } else {
385 14
                        $unnamedRoutes[$name] = 0;
386
                    }
387
                }
388
389 14
                $route['name'] = $prefix.$group->namedPrefix.$name;
390 14
                $collection[] = $route;
391 14
                ++$count;
392
            }
393
394 15
            if (!empty($group->groups)) {
395 2
                $group->injectGroups($prefix.$group->namedPrefix, $collection, $count);
396
            }
397
        }
398
399 15
        $this->groups = [];
400
    }
401
}
402