• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

willy68 / pg-router / 16205018743

10 Jul 2025 08:08PM UTC coverage: 94.928% (-0.05%) from 94.978%
16205018743

push

github

willy68
First commit

3 of 3 new or added lines in 1 file covered. (100.0%)

17 existing lines in 4 files now uncovered.

917 of 966 relevant lines covered (94.93%)

8.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.13
/src/Router.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Pg\Router;
6

7
use Pg\Router\DuplicateDetector\DuplicateDetectorInterface;
8
use Pg\Router\DuplicateDetector\DuplicateMethodMapDetector;
9
use Pg\Router\Generator\UrlGenerator;
10
use Pg\Router\Matcher\MarkDataMatcher;
11
use Pg\Router\Matcher\MatcherInterface;
12
use Pg\Router\Middlewares\MiddlewareAwareStackTrait;
13
use Pg\Router\RegexCollector\MarkRegexCollector;
14
use Pg\Router\RegexCollector\RegexCollectorInterface;
15
use Psr\Cache\CacheException;
16
use Psr\Cache\CacheItemPoolInterface;
17
use Psr\Cache\InvalidArgumentException;
18
use Psr\Http\Message\ServerRequestInterface as Request;
19
use Symfony\Component\Cache\Adapter\PhpFilesAdapter;
20

21
class Router implements RouterInterface
22
{
23
    use MiddlewareAwareStackTrait;
24
    use RouteCollectionTrait;
25

26
    public const CONFIG_CACHE_ENABLED = 'cache_enabled';
27
    public const CONFIG_CACHE_DIR = 'cache_dir';
28
    public const CONFIG_CACHE_POOL_FACTORY = 'cache_pool_factory';
29
    public const CONFIG_DEFAULT_TOKENS = 'default_tokens';
30

31
    private string $cacheDir = 'tmp/cache';
32
    private string $cacheKey = 'router_parsed_data';
33
    /** @var callable: CachePoolInterface */
34
    private $cachePoolFactory = null;
35
    private ?CacheItemPoolInterface $cachePool = null;
36
    private ?array $parsedData = null;
37
    private bool $hasParsedData = false;
38
    protected ?DuplicateDetectorInterface $detector = null;
39
    /** @var Route[] */
40
    protected array $routes = [];
41
    /** @var array  Default tokens ["tokenName" => "regex"]*/
42
    protected array $tokens = [];
43
    /** @var callable(array|object): MatcherInterface|null */
44
    protected $matcherFactory = null;
45
    private ?RegexCollectorInterface $regexCollector;
46

47
    /**
48
     * <code>
49
      $router = new Router (
50
           null,
51
           null,
52
           [
53
               Router::CONFIG_CACHE_ENABLED => ($env === 'prod'),
54
               Router::CONFIG_CACHE_DIR => '/tmp/cache',
55
               Router::CONFIG_CACHE_POOL_FACTORY => function (): CacheItemPoolInterface {...},
56
     *         Router::CONFIG_DEFAULT_TOKENS => ['id' => '[0-9]+', 'slug' => '[a-zA-Z-]+[a-zA-Z0-9_-]+'],
57
           ]
58
      );
59
     * </code>
60
     *
61
     * @param RegexCollectorInterface|null $regexCollector
62
     * @param callable|null $matcherFactory
63
     * @param array|null $config
64
     * @throws CacheException
65
     */
66
    public function __construct(
14✔
67
        ?RegexCollectorInterface $regexCollector = null,
68
        ?callable $matcherFactory = null,
69
        ?array $config = null
70
    ) {
71
        $this->regexCollector = $regexCollector;
14✔
72
        $this->matcherFactory = $matcherFactory;
14✔
73
        $this->loadConfig($config);
14✔
74
    }
75

76
    /**
77
     * Load configuration parameters.
78
     *
79
     * @param array|null $config
80
     * @throws CacheException
81
     */
82
    private function loadConfig(?array $config = null): void
14✔
83
    {
84
        if ($config === null) {
14✔
85
            return;
7✔
86
        }
87

88
        $cacheEnabled = (bool)($config[self::CONFIG_CACHE_ENABLED] ?? false);
7✔
89
        $this->cacheDir = (string)($config[self::CONFIG_CACHE_DIR] ?? $this->cacheDir);
7✔
90
        $this->cachePoolFactory = $config[self::CONFIG_CACHE_POOL_FACTORY] ?? $this->getCachePoolFactory();
7✔
91
        $this->tokens = $config[self::CONFIG_DEFAULT_TOKENS] ?? [];
7✔
92

93
        if ($cacheEnabled) {
7✔
94
            $this->loadCachePool();
5✔
95
            $this->loadParsedData();
5✔
96
        }
97
    }
98

99
    /**
100
     * @throws CacheException
101
     */
102
    private function loadCachePool(): void
5✔
103
    {
104
        if ($this->cachePool === null) {
5✔
105
            $cachePoolFactory = $this->cachePoolFactory;
5✔
106
            if (!is_callable($cachePoolFactory)) {
5✔
UNCOV
107
                throw new \Symfony\Component\Cache\Exception\InvalidArgumentException(
×
108
                    'Cache pool factory must be a callable.'
×
109
                );
×
110
            }
111
            $this->cachePool = $cachePoolFactory();
5✔
112
            if (!$this->cachePool instanceof CacheItemPoolInterface) {
5✔
UNCOV
113
                throw new \Symfony\Component\Cache\Exception\InvalidArgumentException(
×
UNCOV
114
                    'Cache pool factory must return an instance of CacheItemPoolInterface.'
×
UNCOV
115
                );
×
116
            }
117
        }
118
    }
119

120
    /**
121
     * Get the default cache pool factory callable.
122
     *
123
     * @return callable (array|object): CacheItemPoolInterface
124
     */
125
    protected function getCachePoolFactory(): callable
6✔
126
    {
127
        return fn(): CacheItemPoolInterface => new PhpFilesAdapter(
6✔
128
            'Router',
6✔
129
            0,
6✔
130
            $this->cacheDir,
6✔
131
            true
6✔
132
        );
6✔
133
    }
134

135
    public function route(
8✔
136
        string $path,
137
        callable|array|string $callback,
138
        ?string $name = null,
139
        ?array $methods = null
140
    ): Route {
141
        $route = new Route($path, $callback, $name, $methods);
8✔
142
        $this->addRoute($route);
8✔
143
        return $route;
8✔
144
    }
145

146
    public function addRoute(Route $route): Route
10✔
147
    {
148
        $this->duplicateRoute($route);
10✔
149
        $this->routes[$route->getName()] = $route;
10✔
150
        if (!empty($this->tokens)) {
10✔
151
            $route->setTokens($this->tokens);
1✔
152
        }
153
        return $route;
10✔
154
    }
155

156
    protected function duplicateRoute(Route $route): void
10✔
157
    {
158
        $this->getDuplicateDetector()->detectDuplicate($route);
10✔
159
    }
160

161
    protected function getDuplicateDetector(): DuplicateDetectorInterface
10✔
162
    {
163
        return $this->detector ??= new DuplicateMethodMapDetector();
10✔
164
    }
165

166
    /**
167
     * @throws InvalidArgumentException
168
     */
169
    public function match(Request $request): RouteResult
4✔
170
    {
171
        $uri = rawurldecode($request->getUri()->getPath());
4✔
172
        $method = $request->getMethod();
4✔
173
        $matcher = $this->getMatcher();
4✔
174

175
        $route = $matcher->match($uri, $method);
4✔
176

177
        if ($route) {
4✔
178
            return RouteResult::fromRouteSuccess(
3✔
179
                $this->routes[$matcher->getMatchedRouteName()],
3✔
180
                $matcher->getAttributes()
3✔
181
            );
3✔
182
        }
183

184
        $allowedMethods = $matcher->getAllowedMethods();
1✔
185

186
        return RouteResult::fromRouteFailure(!empty($allowedMethods) ? $allowedMethods : null);
1✔
187
    }
188

189
    /**
190
     * @throws InvalidArgumentException
191
     */
192
    protected function getMatcher(?array $routes = null): MatcherInterface
4✔
193
    {
194
        $this->matcherFactory ??= $this->getMatcherFactory();
4✔
195
        $routes ??= $this->getParsedData();
4✔
196
        return ($this->matcherFactory)($routes);
4✔
197
    }
198

199
    /**
200
     * @return callable(array|object): MatcherInterface
201
     */
202
    protected function getMatcherFactory(): callable
4✔
203
    {
204
        return fn($routes): MatcherInterface => new MarkDataMatcher($routes);
4✔
205
    }
206

207
    /**
208
     * @throws InvalidArgumentException
209
     */
210
    protected function getParsedData(): array
6✔
211
    {
212
        if ($this->hasParsedData) {
6✔
213
            return $this->parsedData;
2✔
214
        }
215

216
        $this->getRegexCollector()->addRoutes($this->routes);
6✔
217
        $data = $this->getRegexCollector()->getData();
6✔
218

219
        if ($this->cachePool) {
6✔
220
            $cacheItem = $this->cachePool->getItem($this->cacheKey);
3✔
221
            $cacheItem->set($data);
3✔
222
            $this->cachePool->save($cacheItem);
3✔
223
        }
224

225
        return $data;
6✔
226
    }
227

228
    /**
229
     * @throws InvalidArgumentException
230
     */
231
    protected function loadParsedData(): ?array
5✔
232
    {
233
        $parsedData = null;
5✔
234
        if ($this->cachePool) {
5✔
235
            $cacheItem = $this->cachePool->getItem($this->cacheKey);
5✔
236
            if ($cacheItem->isHit()) {
5✔
237
                $parsedData = $cacheItem->get();
3✔
238
            }
239
        }
240

241
        if ($parsedData === null) {
5✔
242
            return null;
5✔
243
        }
244

245
        if (!is_array($parsedData)) {
3✔
UNCOV
246
            throw new \InvalidArgumentException("Parse data must be an array.");
×
247
        }
248

249
        $this->hasParsedData = true;
3✔
250
        return ($this->parsedData = $parsedData);
3✔
251
    }
252

253
    protected function getRegexCollector(): RegexCollectorInterface
6✔
254
    {
255
        return $this->regexCollector ??= new MarkRegexCollector();
6✔
256
    }
257

258
    public function generateUri(string $name, array $substitutions = [], array $queryParams = []): string
1✔
259
    {
260
        $uri = (new UrlGenerator($this))->generate($name, $substitutions);
1✔
261
        if (!empty($queryParams)) {
1✔
262
            return $uri . '?' . http_build_query($queryParams);
1✔
263
        }
264
        return $uri;
1✔
265
    }
266

267
    /**
268
     * Create multiple routes with the same prefix.
269
     */
UNCOV
270
    public function group(string $prefix, callable $callable): RouteGroup
×
271
    {
UNCOV
272
        $group = new RouteGroup($prefix, $callable, $this);
×
UNCOV
273
        $group();
×
UNCOV
274
        return $group;
×
275
    }
276

277
    /**
278
     * Generate CRUD routes.
279
     *
280
     * @param string $prefixPath
281
     * @param string $callable
282
     * @param string $prefixName
283
     * @return RouteGroup
284
     */
285
    public function crud(string $prefixPath, string $callable, string $prefixName): RouteGroup
1✔
286
    {
287
        $group = new RouteGroup(
1✔
288
            $prefixPath,
1✔
289
            function (RouteGroup $route) use ($callable, $prefixName) {
1✔
290
                $route->crud($callable, $prefixName);
1✔
291
            },
1✔
292
            $this
1✔
293
        );
1✔
294
        $group();
1✔
295
        return $group;
1✔
296
    }
297

298
    /**
299
     * @return Route[]
300
     */
301
    public function getRoutes(): array
1✔
302
    {
303
        return $this->routes;
1✔
304
    }
305

306
    public function getRouteName(string $name): ?Route
3✔
307
    {
308
        return $this->routes[$name] ?? null;
3✔
309
    }
310

311
    /**
312
     * Add new tokens, but preserve existing tokens
313
     * Through this method you can set tokens in an array ["id" => "[0-9]+", "slug" => "[a-zA-Z-]+[a-zA-Z0-9_-]+"]
314
     * @param array $tokens
315
     * @return $this
316
     */
317
    public function setTokens(array $tokens): self
2✔
318
    {
319
        $this->tokens = $this->tokens + $tokens;
2✔
320
        return $this;
2✔
321
    }
322

323
    /**
324
     * Override existing tokens and/or add new
325
     * Through this method you can set tokens in an array ["id" => "[0-9]+", "slug" => "[a-zA-Z0-9_-]+"]
326
     * @param array $tokens
327
     * @return $this
328
     */
329
    public function updateTokens(array $tokens): self
1✔
330
    {
331
        $this->tokens = $tokens + $this->tokens;
1✔
332
        return $this;
1✔
333
    }
334

335
    public function getTokens(): array
3✔
336
    {
337
        return $this->tokens;
3✔
338
    }
339

340
    public function clearCache(): void
1✔
341
    {
342
        $this->cachePool?->clear();
1✔
343
        $this->parsedData = null;
1✔
344
        $this->hasParsedData = false;
1✔
345
    }
346
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc