Skip to content

Commit 3b1387b

Browse files
authored
Release/4.6.0 (#49)
* refactor: Update DecodedRequest and Uri classes for improved route parameter handling.
1 parent bce4e88 commit 3b1387b

File tree

6 files changed

+674
-41
lines changed

6 files changed

+674
-41
lines changed

README.md

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ to access route parameters and JSON body fields consistently.
5353
/** @var ServerRequestInterface $psrRequest */
5454
$decoded = Request::from(request: $psrRequest)->decode();
5555

56-
$name = $decoded->body->get(key: 'name')->toString();
57-
$payload = $decoded->body->toArray();
56+
$name = $decoded->body()->get(key: 'name')->toString();
57+
$payload = $decoded->body()->toArray();
5858

59-
$id = $decoded->uri->route()->get(key: 'id')->toInteger();
59+
$id = $decoded->uri()->route()->get(key: 'id')->toInteger();
6060
```
6161

6262
- **Typed access with defaults**: Each value is returned as an Attribute, which provides safe conversions and default
@@ -67,24 +67,85 @@ to access route parameters and JSON body fields consistently.
6767

6868
$decoded = Request::from(request: $psrRequest)->decode();
6969

70-
$id = $decoded->uri->route()->get(key: 'id')->toInteger(); # default: 0
71-
$note = $decoded->body->get(key: 'note')->toString(); # default: ""
72-
$tags = $decoded->body->get(key: 'tags')->toArray(); # default: []
73-
$price = $decoded->body->get(key: 'price')->toFloat(); # default: 0.00
74-
$active = $decoded->body->get(key: 'active')->toBoolean(); # default: false
70+
$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # default: 0
71+
$note = $decoded->body()->get(key: 'note')->toString(); # default: ""
72+
$tags = $decoded->body()->get(key: 'tags')->toArray(); # default: []
73+
$price = $decoded->body()->get(key: 'price')->toFloat(); # default: 0.00
74+
$active = $decoded->body()->get(key: 'active')->toBoolean(); # default: false
7575
```
7676

7777
- **Custom route attribute name**: If your framework stores route params in a different request attribute, you can
78-
specify it via route().
78+
specify it via `route()`.
7979

8080
```php
8181
use TinyBlocks\Http\Request;
8282

8383
$decoded = Request::from(request: $psrRequest)->decode();
8484

85-
$id = $decoded->uri->route(name: '_route_params')->get(key: 'id')->toInteger();
85+
$id = $decoded->uri()->route(name: '_route_params')->get(key: 'id')->toInteger();
8686
```
8787

88+
#### How route parameters are resolved
89+
90+
The library resolves route parameters from the PSR-7 `ServerRequestInterface` using a **multistep fallback strategy**,
91+
designed to work across different frameworks without importing any framework-specific code.
92+
93+
**Resolution order** (when using the default `route()` or `route(name: '...')`):
94+
95+
1. **Specified attribute lookup** — Reads the attribute from the request using the configured name (default:
96+
`__route__`).
97+
- If the value is an **array**, the key is looked up directly.
98+
- If the value is an **object**, the resolver tries known accessor methods (`getArguments()`,
99+
`getMatchedParams()`, `getParameters()`, `getParams()`) and then public properties (`arguments`, `params`,
100+
`vars`, `parameters`).
101+
- If the value is a **scalar** (e.g., a string), it is returned as-is.
102+
103+
2. **Known attribute scan** (only when using the default `__route__` name) — Scans all commonly used attribute keys
104+
across frameworks:
105+
- `__route__`, `_route_params`, `route`, `routing`, `routeResult`, `routeInfo`
106+
107+
3. **Direct attribute fallback** — As a last resort, tries `$request->getAttribute($key)` directly, which supports
108+
frameworks like Laravel that store route params as individual request attributes.
109+
110+
4. **Safe default** — If nothing is found, returns `Attribute::from(null)`, which provides safe conversions:
111+
`toInteger()``0`, `toString()``""`, `toFloat()``0.00`, `toBoolean()``false`, `toArray()``[]`.
112+
113+
**Supported frameworks and attribute formats:**
114+
115+
| Framework | Attribute Key | Format |
116+
|-------------------------|-----------------|-----------------------------------------------|
117+
| **Slim 4** | `__route__` | Object with `getArguments()` |
118+
| **Mezzio / Expressive** | `routeResult` | Object with `getMatchedParams()` |
119+
| **Symfony** | `_route_params` | `array<string, mixed>` |
120+
| **Laravel** | *(direct)* | `getAttribute('id')` directly on the request |
121+
| **FastRoute (generic)** | `routeInfo` | Array with route parameters |
122+
| **Manual injection** | Any custom key | `$request->withAttribute('__route__', [...])` |
123+
124+
#### Manually injecting route parameters
125+
126+
If your framework or middleware does not automatically populate route attributes, you can inject them manually using
127+
PSR-7's `withAttribute()`:
128+
129+
```php
130+
use TinyBlocks\Http\Request;
131+
132+
$psrRequest = $psrRequest->withAttribute('__route__', [
133+
'id' => '42',
134+
'email' => 'user@example.com'
135+
]);
136+
137+
$decoded = Request::from(request: $psrRequest)->decode();
138+
$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # 42
139+
140+
$psrRequest = $psrRequest->withAttribute('my_params', ['slug' => 'hello-world']);
141+
$slug = Request::from(request: $psrRequest)
142+
->decode()
143+
->uri()
144+
->route(name: 'my_params')
145+
->get(key: 'slug')
146+
->toString(); # "hello-world"
147+
```
148+
88149
<div id='response'></div>
89150

90151
### Response
@@ -184,4 +245,4 @@ Http is licensed under [MIT](LICENSE).
184245
## Contributing
185246

186247
Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
187-
contribute to the project.
248+
contribute to the project.

src/Internal/Request/DecodedRequest.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@
66

77
final readonly class DecodedRequest
88
{
9-
private function __construct(public Uri $uri, public Body $body)
9+
private function __construct(private Uri $uri, private Body $body)
1010
{
1111
}
1212

1313
public static function from(Uri $uri, Body $body): DecodedRequest
1414
{
1515
return new DecodedRequest(uri: $uri, body: $body);
1616
}
17+
18+
public function uri(): Uri
19+
{
20+
return $this->uri;
21+
}
22+
23+
public function body(): Body
24+
{
25+
return $this->body;
26+
}
1727
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\Internal\Request;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
9+
/**
10+
* Resolves route parameters from a PSR-7 ServerRequestInterface in a framework-agnostic way.
11+
*
12+
* Supports multiple attribute formats used by popular frameworks:
13+
* - Plain arrays (e.g., Symfony's `_route_params`)
14+
* - Objects with accessor methods (e.g., Slim's `getArguments()`, Mezzio's `getMatchedParams()`)
15+
* - Objects with public properties (e.g., `arguments`, `params`, `vars`)
16+
* - Direct attributes on the request (e.g., Laravel's `getAttribute('id')`)
17+
*/
18+
final readonly class RouteParameterResolver
19+
{
20+
private const array KNOWN_ATTRIBUTE_KEYS = [
21+
'__route__',
22+
'_route_params',
23+
'route',
24+
'routing',
25+
'routeResult',
26+
'routeInfo'
27+
];
28+
29+
private const array OBJECT_METHODS = [
30+
'getArguments',
31+
'getMatchedParams',
32+
'getParameters',
33+
'getParams'
34+
];
35+
36+
private const array OBJECT_PROPERTIES = [
37+
'arguments',
38+
'params',
39+
'vars',
40+
'parameters'
41+
];
42+
43+
private function __construct(private ServerRequestInterface $request)
44+
{
45+
}
46+
47+
public static function from(ServerRequestInterface $request): RouteParameterResolver
48+
{
49+
return new RouteParameterResolver(request: $request);
50+
}
51+
52+
public function resolve(string $attributeName): array
53+
{
54+
$attribute = $this->request->getAttribute($attributeName);
55+
56+
if (is_array($attribute)) {
57+
return $attribute;
58+
}
59+
60+
if (is_object($attribute)) {
61+
return $this->extractFromObject(object: $attribute);
62+
}
63+
64+
return [];
65+
}
66+
67+
public function resolveFromKnownAttributes(): array
68+
{
69+
foreach (self::KNOWN_ATTRIBUTE_KEYS as $key) {
70+
$parameters = $this->resolve(attributeName: $key);
71+
72+
if (!empty($parameters)) {
73+
return $parameters;
74+
}
75+
}
76+
77+
return [];
78+
}
79+
80+
public function resolveDirectAttribute(string $key): mixed
81+
{
82+
return $this->request->getAttribute($key);
83+
}
84+
85+
private function extractFromObject(object $object): array
86+
{
87+
foreach (self::OBJECT_METHODS as $method) {
88+
if (method_exists($object, $method)) {
89+
$result = $object->{$method}();
90+
91+
if (is_array($result)) {
92+
return $result;
93+
}
94+
}
95+
}
96+
97+
foreach (self::OBJECT_PROPERTIES as $property) {
98+
if (property_exists($object, $property)) {
99+
$value = $object->{$property};
100+
101+
if (is_array($value)) {
102+
return $value;
103+
}
104+
}
105+
}
106+
107+
return [];
108+
}
109+
}

src/Internal/Request/Uri.php

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,95 @@
66

77
use Psr\Http\Message\ServerRequestInterface;
88

9+
/**
10+
* Provides access to route parameters extracted from a PSR-7 ServerRequestInterface.
11+
*
12+
* The route parameters are resolved in the following priority:
13+
* 1. The explicitly specified attribute name (default: `__route__`).
14+
* 2. A scan of all known framework attribute keys.
15+
* 3. Direct attribute lookup on the request (for frameworks like Laravel).
16+
*/
917
final readonly class Uri
1018
{
1119
private const string ROUTE = '__route__';
1220

13-
private function __construct(private ServerRequestInterface $request, private string $routeAttributeName)
14-
{
21+
private function __construct(
22+
private ServerRequestInterface $request,
23+
private string $routeAttributeName,
24+
private RouteParameterResolver $resolver
25+
) {
1526
}
1627

1728
public static function from(ServerRequestInterface $request): Uri
1829
{
19-
return new Uri(request: $request, routeAttributeName: self::ROUTE);
30+
return new Uri(
31+
request: $request,
32+
routeAttributeName: self::ROUTE,
33+
resolver: RouteParameterResolver::from(request: $request)
34+
);
2035
}
2136

37+
/**
38+
* Returns a new Uri instance configured to read route parameters from the given attribute name.
39+
*
40+
* @param string $name The request attribute name where route params are stored.
41+
* @return Uri A new instance targeting the specified attribute.
42+
*/
2243
public function route(string $name = self::ROUTE): Uri
2344
{
24-
return new Uri(request: $this->request, routeAttributeName: $name);
45+
return new Uri(
46+
request: $this->request,
47+
routeAttributeName: $name,
48+
resolver: $this->resolver
49+
);
2550
}
2651

52+
/**
53+
* Retrieves a single route parameter by key.
54+
*
55+
* Resolution order:
56+
* 1. Look up the configured attribute name and extract the key from it.
57+
* 2. If not found, scan all known framework attribute keys.
58+
* 3. If still not found, try a direct `getAttribute($key)` on the request.
59+
* 4. Falls back to `Attribute::from(null)` which provides safe defaults.
60+
*
61+
* @param string $key The route parameter name.
62+
* @return Attribute A typed wrapper around the resolved value.
63+
*/
2764
public function get(string $key): Attribute
2865
{
66+
$value = $this->resolveValue(key: $key);
67+
68+
return Attribute::from(value: $value);
69+
}
70+
71+
private function resolveValue(string $key): mixed
72+
{
73+
$parameters = $this->resolver->resolve(attributeName: $this->routeAttributeName);
74+
75+
if (array_key_exists($key, $parameters)) {
76+
return $parameters[$key];
77+
}
78+
2979
$attribute = $this->request->getAttribute($this->routeAttributeName);
3080

31-
if (is_array($attribute)) {
32-
return Attribute::from(value: $attribute[$key] ?? null);
81+
if (is_scalar($attribute)) {
82+
return $attribute;
83+
}
84+
85+
return $this->resolveFromFallbacks(key: $key);
86+
}
87+
88+
private function resolveFromFallbacks(string $key): mixed
89+
{
90+
if ($this->routeAttributeName === self::ROUTE) {
91+
$allKnown = $this->resolver->resolveFromKnownAttributes();
92+
93+
if (array_key_exists($key, $allKnown)) {
94+
return $allKnown[$key];
95+
}
3396
}
3497

35-
return Attribute::from(value: $attribute);
98+
return $this->resolver->resolveDirectAttribute(key: $key);
3699
}
37100
}

0 commit comments

Comments
 (0)