diff --git a/README.md b/README.md index a93eba6..e8c11b2 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) - * [Using the status code](#status_code) - * [Creating a response](#response) + * [Request](#request) + * [Response](#response) * [License](#license) * [Contributing](#contributing) @@ -14,10 +14,10 @@ ## Overview -Common implementations for HTTP protocol. The library exposes concrete implementations that follow the PSR standards, -specifically designed to operate with [PSR-7](https://www.php-fig.org/psr/psr-7) -and [PSR-15](https://www.php-fig.org/psr/psr-15), providing solutions for building HTTP responses, requests, and other -HTTP-related components. +Common implementations for the HTTP protocol. The library exposes concrete implementations that follow the PSR standards +and are **framework-agnostic**, designed to work consistently across any ecosystem that supports +[PSR-7](https://www.php-fig.org/psr/psr-7) and [PSR-15](https://www.php-fig.org/psr/psr-15), providing solutions for +building HTTP responses, requests, and other HTTP-related components.
@@ -31,58 +31,65 @@ composer require tiny-blocks/http ## How to use -The library exposes interfaces like `Headers` and concrete implementations like `Response`, `ContentType`, and others, -which facilitate construction. +The library exposes interfaces like `Headers` and concrete implementations like `Request`, `Response`, `ContentType`, +and others, which facilitate construction. - + -### Using the status code +### Request -The library exposes a concrete implementation through the `Code` enum. You can retrieve the status codes, their -corresponding messages, and check for various status code ranges using the methods provided. +#### Decoding a request -- **Get message**: Returns the [HTTP status message](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) - associated with the enum's code. +The library provides a small public API to decode a PSR-7 `ServerRequestInterface` into a typed structure, allowing you +to access route parameters and JSON body fields consistently. + +- **Decode a request**: Use `Request::from(...)` to wrap the PSR-7 request and call `decode()`. The decoded object + exposes `uri` and `body`. ```php - use TinyBlocks\Http\Code; - - Code::OK->value; # 200 - Code::OK->message(); # OK - Code::IM_A_TEAPOT->message(); # I'm a teapot - Code::INTERNAL_SERVER_ERROR->message(); # Internal Server Error - ``` + use Psr\Http\Message\ServerRequestInterface; + use TinyBlocks\Http\Request; -- **Check if the code is valid**: Determines if the given code is a valid HTTP status code represented by the enum. + /** @var ServerRequestInterface $psrRequest */ + $decoded = Request::from(request: $psrRequest)->decode(); - ```php - use TinyBlocks\Http\Code; - - Code::isValidCode(code: 200); # true - Code::isValidCode(code: 999); # false + $name = $decoded->body->get(key: 'name')->toString(); + $payload = $decoded->body->toArray(); + + $id = $decoded->uri->route()->get(key: 'id')->toInteger(); ``` -- **Check if the code is an error**: Determines if the given code is in the error range (**4xx** or **5xx**). +- **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\Code; + use TinyBlocks\Http\Request; - Code::isErrorCode(code: 500); # true - Code::isErrorCode(code: 200); # false + $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 ``` -- **Check if the code is a success**: Determines if the given code is in the success range (**2xx**). +- **Custom route attribute name**: If your framework stores route params in a different request attribute, you can + specify it via route(). ```php - use TinyBlocks\Http\Code; + use TinyBlocks\Http\Request; - Code::isSuccessCode(code: 500); # false - Code::isSuccessCode(code: 200); # true + $decoded = Request::from(request: $psrRequest)->decode(); + + $id = $decoded->uri->route(name: '_route_params')->get(key: 'id')->toInteger(); ``` -### Creating a response +### Response + +#### Creating a response The library provides an easy and flexible way to create HTTP responses, allowing you to specify the status code, headers, and body. You can use the `Response` class to generate responses, and the result will always be a @@ -122,6 +129,50 @@ to the [PSR-7](https://www.php-fig.org/psr/psr-7) standard. ->withHeader(name: 'X-NAME', value: 'Xpto'); ``` +#### Using the status code + +The library exposes a concrete implementation through the `Code` enum. You can retrieve the status codes, their +corresponding messages, and check for various status code ranges using the methods provided. + +- **Get message**: Returns the [HTTP status message](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) + associated with the enum's code. + + ```php + use TinyBlocks\Http\Code; + + Code::OK->value; # 200 + Code::OK->message(); # OK + Code::IM_A_TEAPOT->message(); # I'm a teapot + Code::INTERNAL_SERVER_ERROR->message(); # Internal Server Error + ``` + +- **Check if the code is valid**: Determines if the given code is a valid HTTP status code represented by the enum. + + ```php + use TinyBlocks\Http\Code; + + Code::isValidCode(code: 200); # true + Code::isValidCode(code: 999); # false + ``` + +- **Check if the code is an error**: Determines if the given code is in the error range (**4xx** or **5xx**). + + ```php + use TinyBlocks\Http\Code; + + Code::isErrorCode(code: 500); # true + Code::isErrorCode(code: 200); # false + ``` + +- **Check if the code is a success**: Determines if the given code is in the success range (**2xx**). + + ```php + use TinyBlocks\Http\Code; + + Code::isSuccessCode(code: 500); # false + Code::isSuccessCode(code: 200); # true + ``` + ## License diff --git a/composer.json b/composer.json index 247804e..a45b247 100644 --- a/composer.json +++ b/composer.json @@ -41,12 +41,11 @@ }, "autoload-dev": { "psr-4": { - "TinyBlocks\\Http\\": "tests/" + "Test\\TinyBlocks\\Http\\": "tests/" } }, "require": { "php": "^8.5", - "ext-mbstring": "*", "psr/http-message": "^2.0", "tiny-blocks/mapper": "^2.0" }, @@ -59,9 +58,6 @@ "squizlabs/php_codesniffer": "^4.0", "laminas/laminas-httphandlerrunner": "^2.13" }, - "suggest": { - "ext-mbstring": "Provides multibyte-specific string functions that help us deal with multibyte encodings in PHP." - }, "scripts": { "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b283c54..b06e4e1 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,7 @@ parameters: ignoreErrors: - '#expects#' - '#should return#' + - '#mixed to string#' - '#does not accept#' - '#type specified in iterable type#' reportUnmatchedIgnoredErrors: false diff --git a/src/Internal/Request/Attribute.php b/src/Internal/Request/Attribute.php new file mode 100644 index 0000000..c8334b1 --- /dev/null +++ b/src/Internal/Request/Attribute.php @@ -0,0 +1,57 @@ +value) => $this->value, + default => [] + }; + } + + public function toFloat(): float + { + return match (true) { + is_scalar($this->value) => (float)$this->value, + default => 0.00 + }; + } + + public function toString(): string + { + return match (true) { + is_scalar($this->value) => (string)$this->value, + default => '' + }; + } + + public function toInteger(): int + { + return match (true) { + is_scalar($this->value) => (int)$this->value, + default => 0 + }; + } + + public function toBoolean(): bool + { + return match (true) { + is_scalar($this->value) => (bool)$this->value, + default => false + }; + } +} diff --git a/src/Internal/Request/Body.php b/src/Internal/Request/Body.php new file mode 100644 index 0000000..45ec38d --- /dev/null +++ b/src/Internal/Request/Body.php @@ -0,0 +1,39 @@ +getBody(); + $streamFactory = StreamFactory::fromStream(stream: $body); + + if ($streamFactory->isEmptyContent()) { + return new Body(data: []); + } + + return new Body(data: json_decode($streamFactory->content(), true)); + } + + 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/DecodedRequest.php b/src/Internal/Request/DecodedRequest.php new file mode 100644 index 0000000..fcaad72 --- /dev/null +++ b/src/Internal/Request/DecodedRequest.php @@ -0,0 +1,17 @@ +uri, body: $this->body); + } +} diff --git a/src/Internal/Request/Uri.php b/src/Internal/Request/Uri.php new file mode 100644 index 0000000..59ee408 --- /dev/null +++ b/src/Internal/Request/Uri.php @@ -0,0 +1,37 @@ +request, routeAttributeName: $name); + } + + public function get(string $key): Attribute + { + $attribute = $this->request->getAttribute($this->routeAttributeName); + + if (is_array($attribute)) { + return Attribute::from(value: $attribute[$key] ?? null); + } + + return Attribute::from(value: $attribute); + } +} diff --git a/src/Internal/Response/InternalResponse.php b/src/Internal/Response/InternalResponse.php index 66b92f0..88f5dfa 100644 --- a/src/Internal/Response/InternalResponse.php +++ b/src/Internal/Response/InternalResponse.php @@ -10,7 +10,7 @@ use TinyBlocks\Http\Code; use TinyBlocks\Http\Headers; use TinyBlocks\Http\Internal\Exceptions\BadMethodCall; -use TinyBlocks\Http\Internal\Response\Stream\StreamFactory; +use TinyBlocks\Http\Internal\Stream\StreamFactory; final readonly class InternalResponse implements ResponseInterface { diff --git a/src/Internal/Response/Stream/Stream.php b/src/Internal/Stream/Stream.php similarity index 98% rename from src/Internal/Response/Stream/Stream.php rename to src/Internal/Stream/Stream.php index 41734fb..3c90585 100644 --- a/src/Internal/Response/Stream/Stream.php +++ b/src/Internal/Stream/Stream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response\Stream; +namespace TinyBlocks\Http\Internal\Stream; use Psr\Http\Message\StreamInterface; use TinyBlocks\Http\Internal\Exceptions\InvalidResource; @@ -16,7 +16,6 @@ final class Stream implements StreamInterface private const int OFFSET_ZERO = 0; private string $content = ''; - private bool $contentFetched = false; /** diff --git a/src/Internal/Response/Stream/StreamFactory.php b/src/Internal/Stream/StreamFactory.php similarity index 72% rename from src/Internal/Response/Stream/StreamFactory.php rename to src/Internal/Stream/StreamFactory.php index 5686d35..d867f05 100644 --- a/src/Internal/Response/Stream/StreamFactory.php +++ b/src/Internal/Stream/StreamFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response\Stream; +namespace TinyBlocks\Http\Internal\Stream; use BackedEnum; use Psr\Http\Message\StreamInterface; @@ -38,6 +38,21 @@ public static function fromEmptyBody(): StreamFactory return new StreamFactory(body: ''); } + public static function fromStream(StreamInterface $stream): StreamFactory + { + if ($stream->isSeekable()) { + $stream->rewind(); + } + + $body = $stream->getContents(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + return new StreamFactory(body: $body); + } + public function write(): StreamInterface { $this->stream->write(string: $this->body); @@ -46,6 +61,16 @@ public function write(): StreamInterface return $this->stream; } + public function content(): string + { + return (string)$this->body; + } + + public function isEmptyContent(): bool + { + return empty($this->body); + } + private static function toJsonFrom(mixed $body): string { return json_encode($body, JSON_PRESERVE_ZERO_FRACTION); diff --git a/src/Internal/Response/Stream/StreamMetaData.php b/src/Internal/Stream/StreamMetaData.php similarity index 94% rename from src/Internal/Response/Stream/StreamMetaData.php rename to src/Internal/Stream/StreamMetaData.php index e99ce2e..7a85248 100644 --- a/src/Internal/Response/Stream/StreamMetaData.php +++ b/src/Internal/Stream/StreamMetaData.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response\Stream; +namespace TinyBlocks\Http\Internal\Stream; final readonly class StreamMetaData { diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..d11055c --- /dev/null +++ b/src/Request.php @@ -0,0 +1,26 @@ +request)->decode(); + } +} diff --git a/tests/Response/CodeTest.php b/tests/CodeTest.php similarity index 99% rename from tests/Response/CodeTest.php rename to tests/CodeTest.php index 05143d4..4e7b6ed 100644 --- a/tests/Response/CodeTest.php +++ b/tests/CodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Response; +namespace Test\TinyBlocks\Http; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Drivers/Endpoint.php b/tests/Drivers/Endpoint.php index c0c2e6c..13ec1bc 100644 --- a/tests/Drivers/Endpoint.php +++ b/tests/Drivers/Endpoint.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Drivers; +namespace Test\TinyBlocks\Http\Drivers; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index 4d8308b..7fce47c 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -2,26 +2,25 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Drivers\Laminas; +namespace Test\TinyBlocks\Http\Drivers\Laminas; use DateTimeInterface; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use Test\TinyBlocks\Http\Drivers\Endpoint; +use Test\TinyBlocks\Http\Drivers\Middleware; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; use TinyBlocks\Http\Code; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Drivers\Endpoint; -use TinyBlocks\Http\Drivers\Middleware; use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; final class LaminasTest extends TestCase { private SapiEmitter $emitter; - private Middleware $middleware; protected function setUp(): void @@ -33,7 +32,7 @@ protected function setUp(): void /** * @throws Exception */ - public function testSuccessfulRequestProcessingWithLaminas(): void + public function testResponseProcessedWithLaminas(): void { /** @Given a valid request */ $request = $this->createMock(ServerRequestInterface::class); diff --git a/tests/Drivers/Middleware.php b/tests/Drivers/Middleware.php index 92a7d1a..93ff112 100644 --- a/tests/Drivers/Middleware.php +++ b/tests/Drivers/Middleware.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Drivers; +namespace Test\TinyBlocks\Http\Drivers; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index 706e2e0..aa8a896 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -2,26 +2,25 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Drivers\Slim; +namespace Test\TinyBlocks\Http\Drivers\Slim; use DateTimeInterface; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Slim\ResponseEmitter; +use Test\TinyBlocks\Http\Drivers\Endpoint; +use Test\TinyBlocks\Http\Drivers\Middleware; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; use TinyBlocks\Http\Code; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Drivers\Endpoint; -use TinyBlocks\Http\Drivers\Middleware; use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; final class SlimTest extends TestCase { private ResponseEmitter $emitter; - private Middleware $middleware; protected function setUp(): void @@ -33,7 +32,7 @@ protected function setUp(): void /** * @throws Exception */ - public function testSuccessfulRequestProcessingWithSlim(): void + public function testResponseProcessedWithSlim(): void { /** @Given a valid request */ $request = $this->createMock(ServerRequestInterface::class); diff --git a/tests/Response/HeadersTest.php b/tests/HeadersTest.php similarity index 99% rename from tests/Response/HeadersTest.php rename to tests/HeadersTest.php index 430ce06..ccc7ea7 100644 --- a/tests/Response/HeadersTest.php +++ b/tests/HeadersTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Response; +namespace Test\TinyBlocks\Http; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\CacheControl; diff --git a/tests/Internal/Response/Stream/StreamFactoryTest.php b/tests/Internal/Response/Stream/StreamFactoryTest.php deleted file mode 100644 index 4afe47f..0000000 --- a/tests/Internal/Response/Stream/StreamFactoryTest.php +++ /dev/null @@ -1,27 +0,0 @@ -write(); - - /** @Then the stream should contain the written content */ - self::assertInstanceOf(StreamInterface::class, $stream); - self::assertSame($body, $stream->getContents()); - } -} diff --git a/tests/Internal/Stream/StreamFactoryTest.php b/tests/Internal/Stream/StreamFactoryTest.php new file mode 100644 index 0000000..1b5a591 --- /dev/null +++ b/tests/Internal/Stream/StreamFactoryTest.php @@ -0,0 +1,89 @@ +write(); + + /** @Then the stream should contain the written content */ + self::assertSame($body, $stream->getContents()); + } + + public function testFromStreamShouldRewindBeforeAndAfterReadingWhenSeekable(): void + { + /** @Given a seekable stream */ + $stream = $this->createMock(StreamInterface::class); + $stream->method('isSeekable')->willReturn(true); + + /** @And rewind call counter */ + $rewindCalls = 0; + + /** @And rewind increments the counter */ + $stream->method('rewind')->willReturnCallback( + static function () use (&$rewindCalls): void { + $rewindCalls++; + } + ); + + /** @And getContents must be called after the first rewind */ + $stream->method('getContents')->willReturnCallback( + static function () use (&$rewindCalls): string { + self::assertSame(1, $rewindCalls); + return 'body'; + } + ); + + /** @When a StreamFactory is created from the stream */ + StreamFactory::fromStream(stream: $stream); + + /** @Then it must rewind twice (before and after reading) */ + self::assertSame(2, $rewindCalls); + } + + public function testFromStreamShouldNotRewindWhenNotSeekable(): void + { + /** @Given a non-seekable stream */ + $stream = $this->createMock(StreamInterface::class); + $stream->method('isSeekable')->willReturn(false); + + /** @And rewind call counter */ + $rewindCalls = 0; + + /** @And rewind increments the counter */ + $stream->method('rewind')->willReturnCallback( + static function () use (&$rewindCalls): void { + $rewindCalls++; + } + ); + + /** @And getContents must be called without any rewind */ + $stream->method('getContents')->willReturnCallback( + static function () use (&$rewindCalls): string { + self::assertSame(0, $rewindCalls); + return 'body'; + } + ); + + /** @When a StreamFactory is created from the stream */ + StreamFactory::fromStream(stream: $stream); + + /** @Then it must not rewind */ + self::assertSame(0, $rewindCalls); + } +} diff --git a/tests/Internal/Response/Stream/StreamTest.php b/tests/Internal/Stream/StreamTest.php similarity index 98% rename from tests/Internal/Response/Stream/StreamTest.php rename to tests/Internal/Stream/StreamTest.php index 5aca8dc..dbc2c36 100644 --- a/tests/Internal/Response/Stream/StreamTest.php +++ b/tests/Internal/Stream/StreamTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response\Stream; +namespace Test\TinyBlocks\Http\Internal\Stream; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -11,11 +11,12 @@ use TinyBlocks\Http\Internal\Exceptions\NonReadableStream; use TinyBlocks\Http\Internal\Exceptions\NonSeekableStream; use TinyBlocks\Http\Internal\Exceptions\NonWritableStream; +use TinyBlocks\Http\Internal\Stream\Stream; +use TinyBlocks\Http\Internal\Stream\StreamMetaData; final class StreamTest extends TestCase { private mixed $resource; - private ?string $temporary; protected function setUp(): void diff --git a/tests/Models/Amount.php b/tests/Models/Amount.php index d24cbd1..51f6e07 100644 --- a/tests/Models/Amount.php +++ b/tests/Models/Amount.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; final readonly class Amount { diff --git a/tests/Models/Color.php b/tests/Models/Color.php index 9af375e..7e0e034 100644 --- a/tests/Models/Color.php +++ b/tests/Models/Color.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; enum Color { diff --git a/tests/Models/Currency.php b/tests/Models/Currency.php index 621b81b..b6be073 100644 --- a/tests/Models/Currency.php +++ b/tests/Models/Currency.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; enum Currency { diff --git a/tests/Models/Dragon.php b/tests/Models/Dragon.php index b377002..19b38fb 100644 --- a/tests/Models/Dragon.php +++ b/tests/Models/Dragon.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; final readonly class Dragon { diff --git a/tests/Models/Order.php b/tests/Models/Order.php index 172e47d..4a99e76 100644 --- a/tests/Models/Order.php +++ b/tests/Models/Order.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; use TinyBlocks\Mapper\ObjectMappability; use TinyBlocks\Mapper\ObjectMapper; diff --git a/tests/Models/Product.php b/tests/Models/Product.php index 6cd2b75..385c125 100644 --- a/tests/Models/Product.php +++ b/tests/Models/Product.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; use TinyBlocks\Mapper\ObjectMappability; use TinyBlocks\Mapper\ObjectMapper; diff --git a/tests/Models/Products.php b/tests/Models/Products.php index d092f02..ac1cab6 100644 --- a/tests/Models/Products.php +++ b/tests/Models/Products.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; use ArrayIterator; use IteratorAggregate; diff --git a/tests/Models/Status.php b/tests/Models/Status.php index e33d236..649ea07 100644 --- a/tests/Models/Status.php +++ b/tests/Models/Status.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Models; +namespace Test\TinyBlocks\Http\Models; enum Status: int { diff --git a/tests/Response/ProtocolVersionTest.php b/tests/ProtocolVersionTest.php similarity index 94% rename from tests/Response/ProtocolVersionTest.php rename to tests/ProtocolVersionTest.php index 23a0230..2737309 100644 --- a/tests/Response/ProtocolVersionTest.php +++ b/tests/ProtocolVersionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Response; +namespace Test\TinyBlocks\Http; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Response; @@ -23,4 +23,4 @@ public function testProtocolVersion(): void /** @Then the response should use the updated protocol version 3 */ self::assertSame('3', $actual->getProtocolVersion()); } -} +} \ No newline at end of file diff --git a/tests/RequestTest.php b/tests/RequestTest.php new file mode 100644 index 0000000..39618ab --- /dev/null +++ b/tests/RequestTest.php @@ -0,0 +1,195 @@ + PHP_INT_MAX, + 'name' => 'Drakengard Firestorm', + 'type' => 'Dragon', + 'weight' => 6000.00, + 'skills' => ['Fire Breath', 'Flight', 'Regeneration'], + 'is_legendary' => true + ]; + + /** @And this payload is used to create a ServerRequestInterface */ + $stream = $this->createMock(StreamInterface::class); + $stream + ->method('getContents') + ->willReturn(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)); + + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getBody') + ->willReturn($stream); + + /** @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 original 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 testRequestDecodingWithRouteWithSingleAttribute(): void + { + /** @Given a route name to be retrieved */ + $routeName = '/v1/dragons/{id}'; + + /** @And an id to be retrieved from the route attribute */ + $attribute = 'dragon-id'; + + /** @And a ServerRequestInterface with this route attribute */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->with('__route__') + ->willReturn([ + 'name' => $routeName, + 'id' => $attribute + ]); + + /** @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'); + + self::assertSame($attribute, $actual->toString()); + } + + public function testRequestDecodingWithRouteWithMultipleAttributes(): void + { + /** @Given a route name to be retrieved */ + $routeName = '/v1/dragons/{id}/skills/{skill}'; + + /** @And an id and skill to be retrieved from the route attribute */ + $attributes = [ + 'id' => 'dragon-id', + 'skill' => 'dragon-skill', + 'weight' => 6000.00 + ]; + + /** @And a ServerRequestInterface with this route attribute */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->with('__route__') + ->willReturn([ + 'name' => $routeName, + ...$attributes + ]); + + /** @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(); + + self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); + self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); + self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); + } + + #[DataProvider('attributeConversionsProvider')] + public function testRequestWhenAttributeConversions( + string $key, + mixed $value, + string $method, + mixed $expected + ): void { + /** @Given a ServerRequestInterface with a route attribute */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->with('__route__') + ->willReturn([ + 'name' => '/v1/dragons/{id}', + $key => $value + ]); + + /** @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(); + + /** @Then the converted value should match the expected value */ + self::assertSame($expected, $actual); + } + + public function testRequestDecodingWithRouteAttributeAsScalar(): void + { + /** @Given a scalar route attribute value */ + $attribute = 'dragon-id'; + + /** @And a ServerRequestInterface with this route attribute as scalar */ + $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest + ->method('getAttribute') + ->with('__route__') + ->willReturn($attribute); + + /** @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'); + + /** @Then the decoded attribute should match the original scalar value */ + self::assertSame($attribute, $actual->toString()); + } + + public static function attributeConversionsProvider(): array + { + return [ + 'Float attribute conversion toString' => ['weight', 6000.00, 'toString', '6000'], + 'Float attribute conversion toInteger' => ['weight', 6000.00, 'toInteger', 6000], + 'Float attribute conversion toBoolean' => ['weight', 6000.00, 'toBoolean', true], + 'String attribute conversion toArray' => [ + 'skills', + '["Fire Breath", "Flight", "Regeneration"]', + 'toArray', + [] + ], + 'String attribute conversion toFloat' => ['weight', '6000.00', 'toFloat', 6000.00], + 'String attribute conversion toInteger' => ['id', '123', 'toInteger', 123], + 'String attribute conversion toBoolean' => [ + 'is_legendary', + 'true', + 'toBoolean', + true + ], + 'Integer attribute conversion toString' => ['id', 123, 'toString', '123'], + 'Integer attribute conversion toFloat' => ['id', 123, 'toFloat', 123.0], + 'Integer attribute conversion toBoolean' => ['id', 123, 'toBoolean', true], + 'Boolean attribute conversion toString' => ['is_legendary', true, 'toString', '1'], + 'Boolean attribute conversion toInteger' => ['is_legendary', true, 'toInteger', 1], + 'Boolean attribute conversion toFloat' => ['is_legendary', true, 'toFloat', 1.0], + 'Non-scalar attribute conversion toFloat defaults to 0.00' => ['meta', ['x' => 1], 'toFloat', 0.00], + 'Non-scalar attribute conversion toInteger defaults to 0' => ['meta', ['x' => 1], 'toInteger', 0], + 'Non-scalar attribute conversion toString defaults to empty' => ['meta', ['x' => 1], 'toString', ''], + 'Non-scalar attribute conversion toBoolean defaults to false' => ['meta', ['x' => 1], 'toBoolean', false] + ]; + } +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 353ea83..04b24f6 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -2,21 +2,23 @@ declare(strict_types=1); -namespace TinyBlocks\Http; +namespace Test\TinyBlocks\Http; use DateTime; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Test\TinyBlocks\Http\Models\Amount; +use Test\TinyBlocks\Http\Models\Color; +use Test\TinyBlocks\Http\Models\Currency; +use Test\TinyBlocks\Http\Models\Dragon; +use Test\TinyBlocks\Http\Models\Order; +use Test\TinyBlocks\Http\Models\Product; +use Test\TinyBlocks\Http\Models\Products; +use Test\TinyBlocks\Http\Models\Status; +use TinyBlocks\Http\Code; use TinyBlocks\Http\Internal\Exceptions\BadMethodCall; -use TinyBlocks\Http\Internal\Response\Stream\StreamFactory; -use TinyBlocks\Http\Models\Amount; -use TinyBlocks\Http\Models\Color; -use TinyBlocks\Http\Models\Currency; -use TinyBlocks\Http\Models\Dragon; -use TinyBlocks\Http\Models\Order; -use TinyBlocks\Http\Models\Product; -use TinyBlocks\Http\Models\Products; -use TinyBlocks\Http\Models\Status; +use TinyBlocks\Http\Internal\Stream\StreamFactory; +use TinyBlocks\Http\Response; final class ResponseTest extends TestCase { @@ -427,7 +429,25 @@ public static function bodyProviderData(): array new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL)) ]) ), - 'expected' => '{"id":1,"products":[{"name":"Product One","amount":{"value":100.5,"currency":"USD"}},{"name":"Product Two","amount":{"value":200.75,"currency":"BRL"}}]}' + 'expected' => json_encode([ + 'id' => 1, + 'products' => [ + [ + 'name' => 'Product One', + 'amount' => [ + 'value' => 100.50, + 'currency' => 'USD' + ] + ], + [ + 'name' => 'Product Two', + 'amount' => [ + 'value' => 200.75, + 'currency' => 'BRL' + ] + ] + ] + ], JSON_PRESERVE_ZERO_FRACTION) ], 'Boolean true value' => [ 'body' => true,