diff --git a/README.md b/README.md index 5a10a97..08d2933 100644 --- a/README.md +++ b/README.md @@ -59,19 +59,64 @@ to access route parameters and JSON body fields consistently. $id = $decoded->uri()->route()->get(key: 'id')->toInteger(); ``` +- **Access the HTTP method**: Use `method()` directly on the `Request` to retrieve the HTTP verb as a typed `Method` + enum. + + ```php + use Psr\Http\Message\ServerRequestInterface; + use TinyBlocks\Http\Request; + + /** @var ServerRequestInterface $psrRequest */ + $request = Request::from(request: $psrRequest); + + $method = $request->method(); # Method::POST + $method->value; # "POST" + ``` + +- **Access the full URI**: Use `toString()` on the decoded `uri()` to retrieve the complete request URI as a string. + + ```php + use TinyBlocks\Http\Request; + + $decoded = Request::from(request: $psrRequest)->decode(); + + $fullUri = $decoded->uri()->toString(); # "https://api.example.com/v1/dragons?sort=name" + ``` + +- **Access query parameters**: Use `queryParameters()` on the decoded `uri()` to retrieve typed access to query string + values. Each value is returned as an `Attribute`, providing the same safe conversions and defaults as body fields. + + ```php + use TinyBlocks\Http\Request; + + $decoded = Request::from(request: $psrRequest)->decode(); + + $queryParams = $decoded->uri()->queryParameters()->toArray(); # ['sort' => 'name', 'limit' => '50'] + $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # "name" + $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # 50 + $active = $decoded->uri()->queryParameters()->get(key: 'active')->toBoolean(); # default: false + ``` + - **Typed access with defaults**: Each value is returned as an Attribute, which provides safe conversions and default values when the underlying value is missing or not compatible. ```php use TinyBlocks\Http\Request; - $decoded = Request::from(request: $psrRequest)->decode(); + $request = Request::from(request: $psrRequest); + $decoded = $request->decode(); + + $method = $request->method(); # default: Method enum + + $id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # default: 0 + $uri = $decoded->uri()->toString(); # default: "" + $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # default: "" + $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # default: 0 - $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 + $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 diff --git a/src/Internal/Request/Body.php b/src/Internal/Request/Body.php index 45ec38d..dfdd68d 100644 --- a/src/Internal/Request/Body.php +++ b/src/Internal/Request/Body.php @@ -18,11 +18,17 @@ public static function from(ServerRequestInterface $request): Body $body = $request->getBody(); $streamFactory = StreamFactory::fromStream(stream: $body); - if ($streamFactory->isEmptyContent()) { - return new Body(data: []); + if (!$streamFactory->isEmptyContent()) { + return new Body(data: json_decode($streamFactory->content(), true)); } - return new Body(data: json_decode($streamFactory->content(), true)); + $parsedBody = $request->getParsedBody(); + + if (is_array($parsedBody)) { + return new Body(data: $parsedBody); + } + + return new Body(data: []); } public function get(string $key): Attribute diff --git a/src/Internal/Request/Decoder.php b/src/Internal/Request/Decoder.php index 2afe985..b8bf341 100644 --- a/src/Internal/Request/Decoder.php +++ b/src/Internal/Request/Decoder.php @@ -14,7 +14,10 @@ private function __construct(private Uri $uri, private Body $body) public static function from(ServerRequestInterface $request): Decoder { - return new Decoder(uri: Uri::from(request: $request), body: Body::from(request: $request)); + return new Decoder( + uri: Uri::from(request: $request), + body: Body::from(request: $request) + ); } public function decode(): DecodedRequest diff --git a/src/Internal/Request/QueryParameters.php b/src/Internal/Request/QueryParameters.php new file mode 100644 index 0000000..413e17d --- /dev/null +++ b/src/Internal/Request/QueryParameters.php @@ -0,0 +1,31 @@ +getQueryParams()); + } + + public function get(string $key): Attribute + { + $value = ($this->data[$key] ?? null); + + return Attribute::from(value: $value); + } + + public function toArray(): array + { + return $this->data; + } +} diff --git a/src/Internal/Request/Uri.php b/src/Internal/Request/Uri.php index 25564d8..08a1b1e 100644 --- a/src/Internal/Request/Uri.php +++ b/src/Internal/Request/Uri.php @@ -7,7 +7,7 @@ use Psr\Http\Message\ServerRequestInterface; /** - * Provides access to route parameters extracted from a PSR-7 ServerRequestInterface. + * Provides access to URI components and 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__`). @@ -34,6 +34,29 @@ public static function from(ServerRequestInterface $request): Uri ); } + /** + * Returns the full URI of the request as a string. + * + * Delegates to the PSR-7 UriInterface's string representation, + * which includes scheme, host, path, query string, and fragment. + * + * @return string The complete URI string (e.g., "https://api.example.com/v1/dragons?sort=name"). + */ + public function toString(): string + { + return $this->request->getUri()->__toString(); + } + + /** + * Returns a typed wrapper around the query string parameters. + * + * @return QueryParameters Provides typed access to individual query parameters via get(). + */ + public function queryParameters(): QueryParameters + { + return QueryParameters::from(request: $this->request); + } + /** * Returns a new Uri instance configured to read route parameters from the given attribute name. * diff --git a/src/Request.php b/src/Request.php index d11055c..7d65bba 100644 --- a/src/Request.php +++ b/src/Request.php @@ -23,4 +23,9 @@ public function decode(): DecodedRequest { return Decoder::from(request: $this->request)->decode(); } + + public function method(): Method + { + return Method::from(value: $this->request->getMethod()); + } } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 9f30f92..a04b550 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; +use TinyBlocks\Http\Method; use TinyBlocks\Http\Request; final class RequestTest extends TestCase @@ -31,6 +33,9 @@ public function testRequestDecodingWithPayload(): void ->willReturn(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)); $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('POST'); $serverRequest ->method('getBody') ->willReturn($stream); @@ -61,6 +66,9 @@ public function testRequestDecodingWithRouteWithSingleAttribute(): void /** @And a ServerRequestInterface with this route attribute */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -91,6 +99,9 @@ public function testRequestDecodingWithRouteWithMultipleAttributes(): void /** @And a ServerRequestInterface with this route attribute */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -118,6 +129,9 @@ public function testRequestWhenAttributeConversions( ): void { /** @Given a ServerRequestInterface with a route attribute */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -142,6 +156,9 @@ public function testRequestDecodingWithRouteAttributeAsScalar(): void /** @And a ServerRequestInterface with this route attribute as scalar */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -171,6 +188,9 @@ public function getArguments(): array /** @And a ServerRequestInterface with this route object under __route__ */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -191,6 +211,7 @@ public function testRequestDecodingWithMezzioStyleRouteResult(): void { /** @Given a Mezzio-style route result object that uses getMatchedParams() */ $routeResult = new class { + /** @noinspection PhpUnused */ public function getMatchedParams(): array { return ['id' => '99', 'slug' => 'fire-dragon']; @@ -199,6 +220,9 @@ public function getMatchedParams(): array /** @And a ServerRequestInterface with this route result under routeResult */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -218,6 +242,9 @@ public function testRequestDecodingWithSymfonyStyleRouteParams(): void { /** @Given Symfony stores route params as an array under _route_params */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -240,6 +267,9 @@ public function testRequestDecodingWithSymfonyStyleFallbackScan(): void { /** @Given Symfony stores route params under _route_params and default __route__ is null */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -258,6 +288,9 @@ public function testRequestDecodingWithDirectAttributes(): void { /** @Given a framework like Laravel stores route params as direct request attributes */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -278,6 +311,9 @@ public function testRequestDecodingWithManualWithAttribute(): void { /** @Given a user manually injects route params via withAttribute() */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('POST'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -302,6 +338,9 @@ public function testRequestDecodingWithObjectHavingPublicProperty(): void /** @And a ServerRequestInterface with this object under __route__ */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -321,6 +360,9 @@ public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void { /** @Given a ServerRequestInterface with no route attributes at all */ $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); $serverRequest ->method('getAttribute') ->willReturn(null); @@ -336,6 +378,209 @@ public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void self::assertSame([], $route->get(key: 'tags')->toArray()); } + public function testRequestDecodingWithParsedBody(): void + { + /** @Given a payload already parsed by the framework */ + $payload = [ + 'id' => PHP_INT_MAX, + 'name' => 'Drakengard Firestorm', + 'type' => 'Dragon', + 'weight' => 6000.00, + 'skills' => ['Fire Breath', 'Flight', 'Regeneration'], + 'is_legendary' => true + ]; + + /** @And a ServerRequestInterface with an empty stream but a parsed body */ + $stream = $this->createMock(StreamInterface::class); + $stream + ->method('getContents') + ->willReturn(''); + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('POST'); + $serverRequest + ->method('getBody') + ->willReturn($stream); + $serverRequest + ->method('getParsedBody') + ->willReturn($payload); + + /** @When we create the HTTP Request with this ServerRequestInterface */ + $request = Request::from(request: $serverRequest); + + /** @And we decode the body of the HTTP Request */ + $actual = $request->decode()->body(); + + /** @Then the decoded body should match the parsed payload */ + self::assertSame($payload, $actual->toArray()); + self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); + self::assertSame($payload['name'], $actual->get(key: 'name')->toString()); + self::assertSame($payload['type'], $actual->get(key: 'type')->toString()); + self::assertSame($payload['weight'], $actual->get(key: 'weight')->toFloat()); + self::assertSame($payload['skills'], $actual->get(key: 'skills')->toArray()); + self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); + } + + public function testRequestDecodingWithFullUri(): void + { + /** @Given a full URI string */ + $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; + + /** @And a PSR-7 UriInterface mock that returns this URI */ + $uri = $this->createMock(UriInterface::class); + $uri + ->method('__toString') + ->willReturn($expectedUri); + + /** @And a ServerRequestInterface that returns this UriInterface */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); + $serverRequest + ->method('getUri') + ->willReturn($uri); + + /** @When we create the HTTP Request with this ServerRequestInterface */ + $request = Request::from(request: $serverRequest); + + /** @And we retrieve the full URI string from the decoded request */ + $actual = $request->decode()->uri()->toString(); + + /** @Then the URI string should match the expected full URI */ + self::assertSame($expectedUri, $actual); + } + + public function testRequestDecodingWithQueryParameters(): void + { + /** @Given query parameters present in the request URI */ + $queryParams = [ + 'sort' => 'name', + 'order' => 'asc', + 'limit' => '50', + 'active' => 'true' + ]; + + /** @And a ServerRequestInterface that returns these query parameters */ + $stream = $this->createMock(StreamInterface::class); + $stream + ->method('getContents') + ->willReturn(''); + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); + $serverRequest + ->method('getQueryParams') + ->willReturn($queryParams); + $serverRequest + ->method('getBody') + ->willReturn($stream); + + /** @When we create the HTTP Request with this ServerRequestInterface */ + $request = Request::from(request: $serverRequest); + + /** @And we retrieve the query parameters from the decoded request */ + $actual = $request->decode()->uri()->queryParameters(); + + /** @Then the query parameters should match the original values */ + self::assertSame($queryParams, $actual->toArray()); + self::assertSame($queryParams['sort'], $actual->get(key: 'sort')->toString()); + self::assertSame($queryParams['order'], $actual->get(key: 'order')->toString()); + self::assertSame(50, $actual->get(key: 'limit')->toInteger()); + self::assertTrue($actual->get(key: 'active')->toBoolean()); + } + + public function testRequestDecodingWithQueryParametersReturnsDefaultsWhenEmpty(): void + { + /** @Given a ServerRequestInterface with no query parameters */ + $stream = $this->createMock(StreamInterface::class); + $stream + ->method('getContents') + ->willReturn(''); + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('GET'); + $serverRequest + ->method('getQueryParams') + ->willReturn([]); + $serverRequest + ->method('getBody') + ->willReturn($stream); + + /** @When we create the HTTP Request with this ServerRequestInterface */ + $request = Request::from(request: $serverRequest); + + /** @And we try to access query parameters that do not exist */ + $actual = $request->decode()->uri()->queryParameters(); + + /** @Then safe defaults should be returned */ + self::assertSame([], $actual->toArray()); + self::assertSame('', $actual->get(key: 'sort')->toString()); + self::assertSame(0, $actual->get(key: 'page')->toInteger()); + self::assertSame(0.00, $actual->get(key: 'price')->toFloat()); + self::assertFalse($actual->get(key: 'active')->toBoolean()); + } + + public function testRequestWithMethod(): void + { + /** @Given a ServerRequestInterface with POST method */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn('POST'); + + /** @When we create the HTTP Request with this ServerRequestInterface */ + $request = Request::from(request: $serverRequest); + + /** @And we retrieve the HTTP method */ + $actual = $request->method(); + + /** @Then the method should match the expected enum value */ + self::assertSame(Method::POST, $actual); + self::assertSame('POST', $actual->value); + } + + #[DataProvider('httpMethodsProvider')] + public function testRequestWithDifferentHttpMethods(string $methodString, Method $expectedMethod): void + { + /** @Given a ServerRequestInterface with the specified HTTP method */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getMethod') + ->willReturn($methodString); + + /** @When we create the HTTP Request with this ServerRequestInterface */ + $request = Request::from(request: $serverRequest); + + /** @And we retrieve the HTTP method */ + $actual = $request->method(); + + /** @Then the method should match the expected enum value */ + self::assertSame($expectedMethod, $actual); + self::assertSame($methodString, $actual->value); + } + + public static function httpMethodsProvider(): array + { + return [ + 'GET method' => ['GET', Method::GET], + 'PUT method' => ['PUT', Method::PUT], + 'POST method' => ['POST', Method::POST], + 'HEAD method' => ['HEAD', Method::HEAD], + 'PATCH method' => ['PATCH', Method::PATCH], + 'TRACE method' => ['TRACE', Method::TRACE], + 'DELETE method' => ['DELETE', Method::DELETE], + 'OPTIONS method' => ['OPTIONS', Method::OPTIONS], + 'CONNECT method' => ['CONNECT', Method::CONNECT] + ]; + } + public static function attributeConversionsProvider(): array { return [