Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 72 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ to access route parameters and JSON body fields consistently.
/** @var ServerRequestInterface $psrRequest */
$decoded = Request::from(request: $psrRequest)->decode();

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

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

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

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

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

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

```php
use TinyBlocks\Http\Request;

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

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

#### How route parameters are resolved

The library resolves route parameters from the PSR-7 `ServerRequestInterface` using a **multistep fallback strategy**,
designed to work across different frameworks without importing any framework-specific code.

**Resolution order** (when using the default `route()` or `route(name: '...')`):

1. **Specified attribute lookup** — Reads the attribute from the request using the configured name (default:
`__route__`).
- If the value is an **array**, the key is looked up directly.
- If the value is an **object**, the resolver tries known accessor methods (`getArguments()`,
`getMatchedParams()`, `getParameters()`, `getParams()`) and then public properties (`arguments`, `params`,
`vars`, `parameters`).
- If the value is a **scalar** (e.g., a string), it is returned as-is.

2. **Known attribute scan** (only when using the default `__route__` name) — Scans all commonly used attribute keys
across frameworks:
- `__route__`, `_route_params`, `route`, `routing`, `routeResult`, `routeInfo`

3. **Direct attribute fallback** — As a last resort, tries `$request->getAttribute($key)` directly, which supports
frameworks like Laravel that store route params as individual request attributes.

4. **Safe default** — If nothing is found, returns `Attribute::from(null)`, which provides safe conversions:
`toInteger()` → `0`, `toString()` → `""`, `toFloat()` → `0.00`, `toBoolean()` → `false`, `toArray()` → `[]`.

**Supported frameworks and attribute formats:**

| Framework | Attribute Key | Format |
|-------------------------|-----------------|-----------------------------------------------|
| **Slim 4** | `__route__` | Object with `getArguments()` |
| **Mezzio / Expressive** | `routeResult` | Object with `getMatchedParams()` |
| **Symfony** | `_route_params` | `array<string, mixed>` |
| **Laravel** | *(direct)* | `getAttribute('id')` directly on the request |
| **FastRoute (generic)** | `routeInfo` | Array with route parameters |
| **Manual injection** | Any custom key | `$request->withAttribute('__route__', [...])` |

#### Manually injecting route parameters

If your framework or middleware does not automatically populate route attributes, you can inject them manually using
PSR-7's `withAttribute()`:

```php
use TinyBlocks\Http\Request;

$psrRequest = $psrRequest->withAttribute('__route__', [
'id' => '42',
'email' => 'user@example.com'
]);

$decoded = Request::from(request: $psrRequest)->decode();
$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # 42

$psrRequest = $psrRequest->withAttribute('my_params', ['slug' => 'hello-world']);
$slug = Request::from(request: $psrRequest)
->decode()
->uri()
->route(name: 'my_params')
->get(key: 'slug')
->toString(); # "hello-world"
```

<div id='response'></div>

### Response
Expand Down Expand Up @@ -184,4 +245,4 @@ Http is licensed under [MIT](LICENSE).
## Contributing

Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
contribute to the project.
contribute to the project.
12 changes: 11 additions & 1 deletion src/Internal/Request/DecodedRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@

final readonly class DecodedRequest
{
private function __construct(public Uri $uri, public Body $body)
private function __construct(private Uri $uri, private Body $body)
{
}

public static function from(Uri $uri, Body $body): DecodedRequest
{
return new DecodedRequest(uri: $uri, body: $body);
}

public function uri(): Uri
{
return $this->uri;
}

public function body(): Body
{
return $this->body;
}
}
109 changes: 109 additions & 0 deletions src/Internal/Request/RouteParameterResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Internal\Request;

use Psr\Http\Message\ServerRequestInterface;

/**
* Resolves route parameters from a PSR-7 ServerRequestInterface in a framework-agnostic way.
*
* Supports multiple attribute formats used by popular frameworks:
* - Plain arrays (e.g., Symfony's `_route_params`)
* - Objects with accessor methods (e.g., Slim's `getArguments()`, Mezzio's `getMatchedParams()`)
* - Objects with public properties (e.g., `arguments`, `params`, `vars`)
* - Direct attributes on the request (e.g., Laravel's `getAttribute('id')`)
*/
final readonly class RouteParameterResolver
{
private const array KNOWN_ATTRIBUTE_KEYS = [
'__route__',
'_route_params',
'route',
'routing',
'routeResult',
'routeInfo'
];

private const array OBJECT_METHODS = [
'getArguments',
'getMatchedParams',
'getParameters',
'getParams'
];

private const array OBJECT_PROPERTIES = [
'arguments',
'params',
'vars',
'parameters'
];

private function __construct(private ServerRequestInterface $request)
{
}

public static function from(ServerRequestInterface $request): RouteParameterResolver
{
return new RouteParameterResolver(request: $request);
}

public function resolve(string $attributeName): array
{
$attribute = $this->request->getAttribute($attributeName);

if (is_array($attribute)) {
return $attribute;
}

if (is_object($attribute)) {
return $this->extractFromObject(object: $attribute);
}

return [];
}

public function resolveFromKnownAttributes(): array
{
foreach (self::KNOWN_ATTRIBUTE_KEYS as $key) {
$parameters = $this->resolve(attributeName: $key);

if (!empty($parameters)) {
return $parameters;
}
}

return [];
}

public function resolveDirectAttribute(string $key): mixed
{
return $this->request->getAttribute($key);
}

private function extractFromObject(object $object): array
{
foreach (self::OBJECT_METHODS as $method) {
if (method_exists($object, $method)) {
$result = $object->{$method}();

if (is_array($result)) {
return $result;
}
}
}

foreach (self::OBJECT_PROPERTIES as $property) {
if (property_exists($object, $property)) {
$value = $object->{$property};

if (is_array($value)) {
return $value;
}
}
}

return [];
}
}
77 changes: 70 additions & 7 deletions src/Internal/Request/Uri.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,95 @@

use Psr\Http\Message\ServerRequestInterface;

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

private function __construct(private ServerRequestInterface $request, private string $routeAttributeName)
{
private function __construct(
private ServerRequestInterface $request,
private string $routeAttributeName,
private RouteParameterResolver $resolver
) {
}

public static function from(ServerRequestInterface $request): Uri
{
return new Uri(request: $request, routeAttributeName: self::ROUTE);
return new Uri(
request: $request,
routeAttributeName: self::ROUTE,
resolver: RouteParameterResolver::from(request: $request)
);
}

/**
* Returns a new Uri instance configured to read route parameters from the given attribute name.
*
* @param string $name The request attribute name where route params are stored.
* @return Uri A new instance targeting the specified attribute.
*/
public function route(string $name = self::ROUTE): Uri
{
return new Uri(request: $this->request, routeAttributeName: $name);
return new Uri(
request: $this->request,
routeAttributeName: $name,
resolver: $this->resolver
);
}

/**
* Retrieves a single route parameter by key.
*
* Resolution order:
* 1. Look up the configured attribute name and extract the key from it.
* 2. If not found, scan all known framework attribute keys.
* 3. If still not found, try a direct `getAttribute($key)` on the request.
* 4. Falls back to `Attribute::from(null)` which provides safe defaults.
*
* @param string $key The route parameter name.
* @return Attribute A typed wrapper around the resolved value.
*/
public function get(string $key): Attribute
{
$value = $this->resolveValue(key: $key);

return Attribute::from(value: $value);
}

private function resolveValue(string $key): mixed
{
$parameters = $this->resolver->resolve(attributeName: $this->routeAttributeName);

if (array_key_exists($key, $parameters)) {
return $parameters[$key];
}

$attribute = $this->request->getAttribute($this->routeAttributeName);

if (is_array($attribute)) {
return Attribute::from(value: $attribute[$key] ?? null);
if (is_scalar($attribute)) {
return $attribute;
}

return $this->resolveFromFallbacks(key: $key);
}

private function resolveFromFallbacks(string $key): mixed
{
if ($this->routeAttributeName === self::ROUTE) {
$allKnown = $this->resolver->resolveFromKnownAttributes();

if (array_key_exists($key, $allKnown)) {
return $allKnown[$key];
}
}

return Attribute::from(value: $attribute);
return $this->resolver->resolveDirectAttribute(key: $key);
}
}
Loading