diff --git a/README.md b/README.md index e8c11b2..5a10a97 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` | +| **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" +``` +
### Response @@ -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. \ No newline at end of file diff --git a/src/Internal/Request/DecodedRequest.php b/src/Internal/Request/DecodedRequest.php index fcaad72..24dcc5f 100644 --- a/src/Internal/Request/DecodedRequest.php +++ b/src/Internal/Request/DecodedRequest.php @@ -6,7 +6,7 @@ final readonly class DecodedRequest { - private function __construct(public Uri $uri, public Body $body) + private function __construct(private Uri $uri, private Body $body) { } @@ -14,4 +14,14 @@ 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; + } } diff --git a/src/Internal/Request/RouteParameterResolver.php b/src/Internal/Request/RouteParameterResolver.php new file mode 100644 index 0000000..eed47e1 --- /dev/null +++ b/src/Internal/Request/RouteParameterResolver.php @@ -0,0 +1,109 @@ +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 []; + } +} diff --git a/src/Internal/Request/Uri.php b/src/Internal/Request/Uri.php index 59ee408..25564d8 100644 --- a/src/Internal/Request/Uri.php +++ b/src/Internal/Request/Uri.php @@ -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); } } diff --git a/tests/Internal/Request/RouteParameterResolverTest.php b/tests/Internal/Request/RouteParameterResolverTest.php new file mode 100644 index 0000000..6af4222 --- /dev/null +++ b/tests/Internal/Request/RouteParameterResolverTest.php @@ -0,0 +1,214 @@ +createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => ['id' => '42', 'slug' => 'test'], + default => null + }); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: '__route__'); + + /** @Then the array should be returned directly */ + self::assertSame(['id' => '42', 'slug' => 'test'], $params); + } + + public function testResolveWithObjectUsingGetArguments(): void + { + /** @Given a Slim-style route object */ + $routeObject = new class { + public function getArguments(): array + { + return ['id' => '1', 'name' => 'dragon']; + } + }; + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $routeObject, + default => null + }); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: '__route__'); + + /** @Then getArguments() result should be returned */ + self::assertSame(['id' => '1', 'name' => 'dragon'], $params); + } + + public function testResolveWithObjectUsingGetMatchedParams(): void + { + /** @Given a Mezzio-style route result object */ + $routeResult = new class { + public function getMatchedParams(): array + { + return ['id' => '99', 'action' => 'view']; + } + }; + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + 'routeResult' => $routeResult, + default => null + }); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: 'routeResult'); + + /** @Then getMatchedParams() result should be returned */ + self::assertSame(['id' => '99', 'action' => 'view'], $params); + } + + public function testResolveWithObjectUsingPublicProperty(): void + { + /** @Given a route object with a public arguments property */ + $routeObject = new class { + public array $arguments = ['key' => 'value']; + }; + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $routeObject, + default => null + }); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: '__route__'); + + /** @Then the public property value should be returned */ + self::assertSame(['key' => 'value'], $params); + } + + public function testResolveReturnsEmptyArrayWhenAttributeIsNull(): void + { + /** @Given a request with no matching attribute */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturn(null); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: '__route__'); + + /** @Then an empty array should be returned */ + self::assertSame([], $params); + } + + public function testResolveReturnsEmptyArrayForUnextractableObject(): void + { + /** @Given a route object without known methods or properties */ + $routeObject = new class { + public function unknownMethod(): string + { + return 'not useful'; + } + }; + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $routeObject, + default => null + }); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: '__route__'); + + /** @Then an empty array should be returned */ + self::assertSame([], $params); + } + + public function testResolveFromKnownAttributesScansMultipleKeys(): void + { + /** @Given params stored under _route_params (Symfony-style) */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '_route_params' => ['controller' => 'DragonController', 'id' => '5'], + default => null + }); + + /** @When scanning known attributes */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolveFromKnownAttributes(); + + /** @Then the Symfony-style params should be found */ + self::assertSame(['controller' => 'DragonController', 'id' => '5'], $params); + } + + public function testResolveDirectAttribute(): void + { + /** @Given a request with direct attributes (Laravel-style) */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + 'id' => '123', + default => null + }); + + /** @When resolving a direct attribute */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + + /** @Then the direct value should be returned */ + self::assertSame('123', $resolver->resolveDirectAttribute(key: 'id')); + self::assertNull($resolver->resolveDirectAttribute(key: 'nonexistent')); + } + + public function testResolveWithObjectMethodPriorityOverProperty(): void + { + /** @Given an object that has both a method and a property */ + $routeObject = new class { + public array $arguments = ['source' => 'property']; + + public function getArguments(): array + { + return ['source' => 'method']; + } + }; + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $routeObject, + default => null + }); + + /** @When resolving parameters */ + $resolver = RouteParameterResolver::from(request: $serverRequest); + $params = $resolver->resolve(attributeName: '__route__'); + + /** @Then the method result should take priority */ + self::assertSame(['source' => 'method'], $params); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 39618ab..9f30f92 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -39,7 +39,7 @@ public function testRequestDecodingWithPayload(): void $request = Request::from(request: $serverRequest); /** @And we decode the body of the HTTP Request */ - $actual = $request->decode()->body; + $actual = $request->decode()->body(); /** @Then the decoded body should match the original payload */ self::assertSame($payload, $actual->toArray()); @@ -63,17 +63,16 @@ public function testRequestDecodingWithRouteWithSingleAttribute(): void $serverRequest = $this->createMock(ServerRequestInterface::class); $serverRequest ->method('getAttribute') - ->with('__route__') - ->willReturn([ - 'name' => $routeName, - 'id' => $attribute - ]); + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => ['name' => $routeName, 'id' => $attribute], + default => null + }); /** @When we create the HTTP Request with this ServerRequestInterface */ $request = Request::from(request: $serverRequest); /** @And we decode the route attribute of the HTTP Request */ - $actual = $request->decode()->uri->route()->get(key: 'id'); + $actual = $request->decode()->uri()->route()->get(key: 'id'); self::assertSame($attribute, $actual->toString()); } @@ -94,17 +93,16 @@ public function testRequestDecodingWithRouteWithMultipleAttributes(): void $serverRequest = $this->createMock(ServerRequestInterface::class); $serverRequest ->method('getAttribute') - ->with('__route__') - ->willReturn([ - 'name' => $routeName, - ...$attributes - ]); + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => ['name' => $routeName, ...$attributes], + default => null + }); /** @When we create the HTTP Request with this ServerRequestInterface */ $request = Request::from(request: $serverRequest); /** @And we decode the route attribute of the HTTP Request */ - $route = $request->decode()->uri->route(); + $route = $request->decode()->uri()->route(); self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); @@ -122,17 +120,16 @@ public function testRequestWhenAttributeConversions( $serverRequest = $this->createMock(ServerRequestInterface::class); $serverRequest ->method('getAttribute') - ->with('__route__') - ->willReturn([ - 'name' => '/v1/dragons/{id}', - $key => $value - ]); + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => ['name' => '/v1/dragons/{id}', $key => $value], + default => null + }); /** @When we create the HTTP Request with this ServerRequestInterface */ $request = Request::from(request: $serverRequest); /** @And we decode the route attribute of the HTTP Request and convert it to the expected type */ - $actual = $request->decode()->uri->route()->get(key: $key)->$method(); + $actual = $request->decode()->uri()->route()->get(key: $key)->$method(); /** @Then the converted value should match the expected value */ self::assertSame($expected, $actual); @@ -147,19 +144,198 @@ public function testRequestDecodingWithRouteAttributeAsScalar(): void $serverRequest = $this->createMock(ServerRequestInterface::class); $serverRequest ->method('getAttribute') - ->with('__route__') - ->willReturn($attribute); + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $attribute, + default => null + }); /** @When we create the HTTP Request with this ServerRequestInterface */ $request = Request::from(request: $serverRequest); /** @And we decode the route attribute of the HTTP Request */ - $actual = $request->decode()->uri->route()->get(key: 'id'); + $actual = $request->decode()->uri()->route()->get(key: 'id'); /** @Then the decoded attribute should match the original scalar value */ self::assertSame($attribute, $actual->toString()); } + public function testRequestDecodingWithSlimStyleRouteObject(): void + { + /** @Given a Slim-style route object that stores params in getArguments() */ + $routeObject = new class { + public function getArguments(): array + { + return ['id' => '42', 'email' => 'dragon@fire.com']; + } + }; + + /** @And a ServerRequestInterface with this route object under __route__ */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $routeObject, + default => null + }); + + /** @When we create the HTTP Request and decode route params */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the params should be correctly resolved from the object */ + self::assertSame('42', $route->get(key: 'id')->toString()); + self::assertSame(42, $route->get(key: 'id')->toInteger()); + self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); + } + + public function testRequestDecodingWithMezzioStyleRouteResult(): void + { + /** @Given a Mezzio-style route result object that uses getMatchedParams() */ + $routeResult = new class { + public function getMatchedParams(): array + { + return ['id' => '99', 'slug' => 'fire-dragon']; + } + }; + + /** @And a ServerRequestInterface with this route result under routeResult */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + 'routeResult' => $routeResult, + default => null + }); + + /** @When we create the HTTP Request and decode using known attribute scan */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the params should be correctly resolved from the Mezzio object */ + self::assertSame('99', $route->get(key: 'id')->toString()); + self::assertSame('fire-dragon', $route->get(key: 'slug')->toString()); + } + + public function testRequestDecodingWithSymfonyStyleRouteParams(): void + { + /** @Given Symfony stores route params as an array under _route_params */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '_route_params' => ['id' => '7', 'category' => 'legendary'], + default => null + }); + + /** @When we use the custom route attribute name */ + $route = Request::from(request: $serverRequest) + ->decode() + ->uri() + ->route(name: '_route_params'); + + /** @Then the params should be correctly resolved */ + self::assertSame('7', $route->get(key: 'id')->toString()); + self::assertSame('legendary', $route->get(key: 'category')->toString()); + } + + public function testRequestDecodingWithSymfonyStyleFallbackScan(): void + { + /** @Given Symfony stores route params under _route_params and default __route__ is null */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '_route_params' => ['id' => '55'], + default => null + }); + + /** @When we use the default route() without specifying a name */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the fallback scan should find params under _route_params */ + self::assertSame('55', $route->get(key: 'id')->toString()); + } + + public function testRequestDecodingWithDirectAttributes(): void + { + /** @Given a framework like Laravel stores route params as direct request attributes */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + 'id' => '123', + 'email' => 'user@example.com', + default => null + }); + + /** @When we decode route params using the default route */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then direct attributes should be resolved as fallback */ + self::assertSame('123', $route->get(key: 'id')->toString()); + self::assertSame('user@example.com', $route->get(key: 'email')->toString()); + } + + public function testRequestDecodingWithManualWithAttribute(): void + { + /** @Given a user manually injects route params via withAttribute() */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => ['id' => 'manually-injected', 'status' => 'active'], + default => null + }); + + /** @When we decode route params */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then the manually injected values should be returned */ + self::assertSame('manually-injected', $route->get(key: 'id')->toString()); + self::assertSame('active', $route->get(key: 'status')->toString()); + } + + public function testRequestDecodingWithObjectHavingPublicProperty(): void + { + /** @Given an object that exposes route params via a public property */ + $routeObject = new class { + public array $arguments = ['id' => '10', 'name' => 'Hydra']; + }; + + /** @And a ServerRequestInterface with this object under __route__ */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturnCallback(static fn(string $name) => match ($name) { + '__route__' => $routeObject, + default => null + }); + + /** @When we decode route params */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then public property values should be resolved */ + self::assertSame('10', $route->get(key: 'id')->toString()); + self::assertSame('Hydra', $route->get(key: 'name')->toString()); + } + + public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void + { + /** @Given a ServerRequestInterface with no route attributes at all */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->willReturn(null); + + /** @When we try to decode route params */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then safe defaults should be returned */ + self::assertSame(0, $route->get(key: 'id')->toInteger()); + self::assertSame('', $route->get(key: 'name')->toString()); + self::assertSame(0.00, $route->get(key: 'weight')->toFloat()); + self::assertFalse($route->get(key: 'active')->toBoolean()); + self::assertSame([], $route->get(key: 'tags')->toArray()); + } + public static function attributeConversionsProvider(): array { return [