Skip to content

Commit fd0b11a

Browse files
Release router 2.6.0
1 parent 3258190 commit fd0b11a

8 files changed

Lines changed: 507 additions & 228 deletions

File tree

packages/router/CHANGELOG.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
# Changelog
22

3-
All significant changes to this project will be documented in this file.
4-
5-
# Changelog
6-
7-
## [2.5.0] - 2025-11-16
3+
All significant changes to this project will be documented in this file.
4+
5+
## [2.6.0] - 2026-06-10
6+
7+
### Added
8+
9+
- Added route inspection APIs used by Annabel route caching and optimization.
10+
11+
### Changed
12+
13+
- Refined route, route collection, and route group behavior for framework cache/attribute workflows.
14+
15+
## [2.5.0] - 2025-11-16
816

917
### Added
1018

packages/router/README.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ composer require codemonster-ru/router
2626
```php
2727
use Codemonster\Router\Router;
2828

29-
$router = new Router();
30-
31-
$router->get('/', fn() => 'Home Page');
32-
$router->get('/about', fn() => 'About Us');
29+
$router = new Router();
30+
31+
$router->get('/', fn() => 'Home Page');
32+
$router->get('/about', fn() => 'About Us');
33+
$router->get('/users/{id}', fn(string $id) => "User {$id}")
34+
->where('id', '\d+')
35+
->name('users.show');
3336

3437
$result = $router->dispatch(
3538
$_SERVER['REQUEST_METHOD'],
@@ -40,16 +43,21 @@ if ($result === null) {
4043
http_response_code(404);
4144

4245
echo 'Not Found';
43-
} else {
44-
echo $result;
45-
}
46-
```
47-
48-
## ✨ Features
49-
50-
- Simple route registration (`get`, `post`, `any`)
51-
- Support for callbacks, `[Controller::class, 'method']` controllers, and `Controller@method` strings
52-
- Returns a **pure result**, without binding to a specific `Response`
46+
} else {
47+
echo $result;
48+
}
49+
50+
echo $router->route('users.show', ['id' => 42]); // /users/42
51+
```
52+
53+
## ✨ Features
54+
55+
- Simple route registration (`get`, `post`, `any`)
56+
- Dynamic route parameters (`/users/{id}`)
57+
- Route parameter constraints with `where()`
58+
- Named routes and URI generation
59+
- Support for callbacks, `[Controller::class, 'method']` controllers, and `Controller@method` strings
60+
- Returns a **pure result**, without binding to a specific `Response`
5361

5462
## 🧪 Testing
5563

packages/router/composer.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@
5555
"issues": "https://github.com/codemonster-ru/router/issues",
5656
"source": "https://github.com/codemonster-ru/router"
5757
},
58-
"extra": {
59-
"branch-alias": {
60-
"dev-main": "2.5.x-dev"
61-
}
62-
}
63-
}
58+
"extra": {
59+
"branch-alias": {
60+
"dev-main": "2.6.x-dev"
61+
}
62+
}
63+
}

packages/router/src/Route.php

Lines changed: 149 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
<?php
2-
3-
namespace Codemonster\Router;
4-
1+
<?php
2+
3+
namespace Codemonster\Router;
4+
55
class Route
66
{
77
/** @var list<string> */
@@ -10,26 +10,156 @@ class Route
1010
public mixed $handler;
1111
/** @var list<list<string|array<mixed>>> */
1212
protected array $middleware = [];
13+
protected ?string $name = null;
14+
/** @var array<string, string> */
15+
protected array $parameters = [];
16+
/** @var array<string, string> */
17+
protected array $constraints = [];
1318

1419
/** @param string|list<string> $methods */
1520
public function __construct(array|string $methods, string $path, mixed $handler)
16-
{
17-
$this->methods = (array)$methods;
18-
$this->path = $path;
19-
$this->handler = $handler;
20-
}
21-
21+
{
22+
$this->methods = array_values(array_map('strtoupper', (array) $methods));
23+
$this->path = $path;
24+
$this->handler = $handler;
25+
}
26+
2227
/** @param string|array<mixed> ...$middleware */
2328
public function middleware(string|array ...$middleware): static
24-
{
29+
{
2530
$this->middleware[] = array_values($middleware);
26-
27-
return $this;
28-
}
29-
31+
32+
return $this;
33+
}
34+
3035
/** @return list<list<string|array<mixed>>> */
3136
public function getMiddleware(): array
32-
{
33-
return $this->middleware;
34-
}
35-
}
37+
{
38+
return $this->middleware;
39+
}
40+
41+
public function name(string $name): static
42+
{
43+
$this->name = $name;
44+
45+
return $this;
46+
}
47+
48+
public function getName(): ?string
49+
{
50+
return $this->name;
51+
}
52+
53+
/** @return array<string, string> */
54+
public function getConstraints(): array
55+
{
56+
return $this->constraints;
57+
}
58+
59+
/** @param array<string, string>|string $name */
60+
public function where(array|string $name, ?string $pattern = null): static
61+
{
62+
if (is_array($name)) {
63+
foreach ($name as $key => $value) {
64+
$this->constraints[$key] = $value;
65+
}
66+
67+
return $this;
68+
}
69+
70+
if ($pattern === null) {
71+
throw new \InvalidArgumentException('Route constraint pattern is required.');
72+
}
73+
74+
$this->constraints[$name] = $pattern;
75+
76+
return $this;
77+
}
78+
79+
public function matches(string $method, string $uri): bool
80+
{
81+
$this->parameters = [];
82+
83+
if (!in_array(strtoupper($method), $this->methods, true)) {
84+
return false;
85+
}
86+
87+
$matches = [];
88+
if (preg_match($this->regex(), $uri, $matches) !== 1) {
89+
return false;
90+
}
91+
92+
foreach ($this->parameterNames() as $name) {
93+
if (isset($matches[$name]) && is_string($matches[$name])) {
94+
$this->parameters[$name] = rawurldecode($matches[$name]);
95+
}
96+
}
97+
98+
return true;
99+
}
100+
101+
/** @return array<string, string> */
102+
public function parameters(): array
103+
{
104+
return $this->parameters;
105+
}
106+
107+
/** @param array<string, scalar|null> $parameters */
108+
public function uri(array $parameters = []): string
109+
{
110+
$used = [];
111+
$uri = preg_replace_callback(
112+
'/\{([A-Za-z_][A-Za-z0-9_]*)\}/',
113+
function (array $matches) use ($parameters, &$used): string {
114+
$name = $matches[1];
115+
116+
if (!array_key_exists($name, $parameters)) {
117+
throw new \InvalidArgumentException("Missing route parameter [{$name}].");
118+
}
119+
120+
$used[] = $name;
121+
122+
return rawurlencode((string) $parameters[$name]);
123+
},
124+
$this->path,
125+
);
126+
127+
if (!is_string($uri)) {
128+
throw new \RuntimeException("Unable to generate URI for route [{$this->path}].");
129+
}
130+
131+
$query = array_diff_key($parameters, array_flip($used));
132+
if ($query !== []) {
133+
$uri .= '?' . http_build_query($query);
134+
}
135+
136+
return $uri;
137+
}
138+
139+
protected function regex(): string
140+
{
141+
$regex = '';
142+
$offset = 0;
143+
preg_match_all('/\{([A-Za-z_][A-Za-z0-9_]*)\}/', $this->path, $matches, PREG_OFFSET_CAPTURE);
144+
145+
foreach ($matches[0] as $index => $match) {
146+
[$placeholder, $position] = $match;
147+
$name = $matches[1][$index][0];
148+
$regex .= preg_quote(substr($this->path, $offset, $position - $offset), '#');
149+
$regex .= '(?P<' . $name . '>' . ($this->constraints[$name] ?? '[^/]+') . ')';
150+
$offset = $position + strlen($placeholder);
151+
}
152+
153+
$regex .= preg_quote(substr($this->path, $offset), '#');
154+
155+
return '#^' . $regex . '$#';
156+
}
157+
158+
/** @return list<string> */
159+
protected function parameterNames(): array
160+
{
161+
preg_match_all('/\{([A-Za-z_][A-Za-z0-9_]*)\}/', $this->path, $matches);
162+
163+
return $matches[1];
164+
}
165+
}
Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,54 @@
1-
<?php
2-
3-
namespace Codemonster\Router;
4-
5-
class RouteCollection
6-
{
7-
/** @var Route[] */
8-
protected array $routes = [];
9-
10-
public function addRoute(Route $route): void
11-
{
12-
foreach ($this->routes as $existing) {
13-
if (
14-
$existing->path === $route->path &&
15-
array_intersect($existing->methods, $route->methods)
16-
) {
17-
throw new \RuntimeException(
18-
"Duplicate route detected: [" . implode('|', $route->methods) . " {$route->path}]"
19-
);
20-
}
21-
}
22-
23-
$this->routes[] = $route;
24-
}
25-
26-
public function match(string $method, string $uri): ?Route
27-
{
28-
foreach ($this->routes as $route) {
29-
if (in_array($method, $route->methods, true) && $route->path === $uri) {
30-
return $route;
31-
}
32-
}
33-
34-
return null;
35-
}
36-
}
1+
<?php
2+
3+
namespace Codemonster\Router;
4+
5+
class RouteCollection
6+
{
7+
/** @var list<Route> */
8+
protected array $routes = [];
9+
10+
public function addRoute(Route $route): void
11+
{
12+
foreach ($this->routes as $existing) {
13+
if (
14+
$existing->path === $route->path &&
15+
array_intersect($existing->methods, $route->methods)
16+
) {
17+
throw new \RuntimeException(
18+
'Duplicate route detected: [' . implode('|', $route->methods) . " {$route->path}]",
19+
);
20+
}
21+
}
22+
23+
$this->routes[] = $route;
24+
}
25+
26+
/** @return list<Route> */
27+
public function all(): array
28+
{
29+
return $this->routes;
30+
}
31+
32+
public function match(string $method, string $uri): ?Route
33+
{
34+
foreach ($this->routes as $route) {
35+
if ($route->matches($method, $uri)) {
36+
return $route;
37+
}
38+
}
39+
40+
return null;
41+
}
42+
43+
/** @param array<string, scalar|null> $parameters */
44+
public function route(string $name, array $parameters = []): string
45+
{
46+
foreach ($this->routes as $route) {
47+
if ($route->getName() === $name) {
48+
return $route->uri($parameters);
49+
}
50+
}
51+
52+
throw new \RuntimeException("Route [{$name}] is not defined.");
53+
}
54+
}

0 commit comments

Comments
 (0)