From 037729e37c3209c4336fc11d766c1ac41c785c1b Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 19 Dec 2024 00:26:27 -0300 Subject: [PATCH] Improves lib's separation of responsibilities and using. (#33) --- .github/workflows/auto-assign.yml | 2 +- .github/workflows/ci.yml | 32 +- Makefile | 19 +- README.md | 136 ++++-- composer.json | 35 +- infection.json.dist | 26 +- phpmd.xml | 1 - phpstan.neon.dist | 8 +- phpunit.xml | 6 +- src/CacheControl.php | 29 ++ src/Charset.php | 24 ++ src/{HttpCode.php => Code.php} | 57 ++- src/ContentType.php | 57 +++ src/Headers.php | 19 + src/HttpContentType.php | 31 -- src/HttpHeaders.php | 70 ---- src/HttpResponse.php | 67 --- .../CacheControl/CacheControlDirective.php | 42 ++ src/Internal/CacheControl/Directives.php | 27 ++ src/Internal/Exceptions/BadMethodCall.php | 1 + src/Internal/Header.php | 22 - src/Internal/Response.php | 106 ----- src/Internal/Response/InternalResponse.php | 150 +++++++ src/Internal/Response/ProtocolVersion.php | 24 ++ src/Internal/Response/ResponseHeaders.php | 61 +++ src/Internal/{ => Response}/Stream/Stream.php | 17 +- .../Response/Stream/StreamFactory.php | 53 +++ .../{ => Response}/Stream/StreamMetaData.php | 2 +- src/Internal/Stream/StreamFactory.php | 28 -- src/{HttpMethod.php => Method.php} | 2 +- src/MimeType.php | 21 + src/Response.php | 56 +++ src/ResponseCacheDirectives.php | 28 ++ src/Responses.php | 95 +++++ tests/Drivers/Endpoint.php | 21 + tests/Drivers/Middleware.php | 18 + tests/Drivers/Slim/RequestFactory.php | 41 ++ tests/Drivers/Slim/SlimTest.php | 56 +++ tests/HttpCodeTest.php | 97 ----- tests/HttpResponseTest.php | 176 -------- tests/Internal/HttpHeadersTest.php | 75 ---- .../Response/Stream/StreamFactoryTest.php | 27 ++ .../{ => Response}/Stream/StreamTest.php | 30 +- tests/Internal/ResponseTest.php | 146 ------- tests/Models/Amount.php | 12 + tests/Models/Color.php | 10 + tests/Models/Currency.php | 11 + tests/Models/Dragon.php | 12 + tests/Models/{Xpto.php => Order.php} | 6 +- tests/Models/Product.php | 17 + tests/Models/Products.php | 28 ++ tests/Models/Status.php | 10 + tests/Models/Xyz.php | 10 - tests/Response/CodeTest.php | 152 +++++++ tests/Response/HeadersTest.php | 208 ++++++++++ tests/Response/ProtocolVersionTest.php | 26 ++ tests/ResponseTest.php | 390 ++++++++++++++++++ 57 files changed, 1965 insertions(+), 968 deletions(-) create mode 100644 src/CacheControl.php create mode 100644 src/Charset.php rename src/{HttpCode.php => Code.php} (61%) create mode 100644 src/ContentType.php create mode 100644 src/Headers.php delete mode 100644 src/HttpContentType.php delete mode 100644 src/HttpHeaders.php delete mode 100644 src/HttpResponse.php create mode 100644 src/Internal/CacheControl/CacheControlDirective.php create mode 100644 src/Internal/CacheControl/Directives.php delete mode 100644 src/Internal/Header.php delete mode 100644 src/Internal/Response.php create mode 100644 src/Internal/Response/InternalResponse.php create mode 100644 src/Internal/Response/ProtocolVersion.php create mode 100644 src/Internal/Response/ResponseHeaders.php rename src/Internal/{ => Response}/Stream/Stream.php (90%) create mode 100644 src/Internal/Response/Stream/StreamFactory.php rename src/Internal/{ => Response}/Stream/StreamMetaData.php (94%) delete mode 100644 src/Internal/Stream/StreamFactory.php rename src/{HttpMethod.php => Method.php} (95%) create mode 100644 src/MimeType.php create mode 100644 src/Response.php create mode 100644 src/ResponseCacheDirectives.php create mode 100644 src/Responses.php create mode 100644 tests/Drivers/Endpoint.php create mode 100644 tests/Drivers/Middleware.php create mode 100644 tests/Drivers/Slim/RequestFactory.php create mode 100644 tests/Drivers/Slim/SlimTest.php delete mode 100644 tests/HttpCodeTest.php delete mode 100644 tests/HttpResponseTest.php delete mode 100644 tests/Internal/HttpHeadersTest.php create mode 100644 tests/Internal/Response/Stream/StreamFactoryTest.php rename tests/Internal/{ => Response}/Stream/StreamTest.php (92%) delete mode 100644 tests/Internal/ResponseTest.php create mode 100644 tests/Models/Amount.php create mode 100644 tests/Models/Color.php create mode 100644 tests/Models/Currency.php create mode 100644 tests/Models/Dragon.php rename tests/Models/{Xpto.php => Order.php} (53%) create mode 100644 tests/Models/Product.php create mode 100644 tests/Models/Products.php create mode 100644 tests/Models/Status.php delete mode 100644 tests/Models/Xyz.php create mode 100644 tests/Response/CodeTest.php create mode 100644 tests/Response/HeadersTest.php create mode 100644 tests/Response/ProtocolVersionTest.php create mode 100644 tests/ResponseTest.php diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 6a9bba4..9280dc2 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Assign issues - uses: gustavofreze/auto-assign@1.0.0 + uses: gustavofreze/auto-assign@1.1.4 with: assignees: '${{ secrets.ASSIGNEES }}' github_token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105bc90..358ac92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: - push: pull_request: permissions: contents: read +env: + PHP_VERSION: '8.3' + jobs: auto-review: name: Auto review @@ -14,21 +16,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Use PHP 8.2 + - name: Configure PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: ${{ env.PHP_VERSION }} - name: Install dependencies run: composer update --no-progress --optimize-autoloader - - name: Run phpcs - run: composer phpcs - - - name: Run phpmd - run: composer phpmd + - name: Run review + run: composer review tests: name: Tests @@ -36,20 +35,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Use PHP 8.2 + - name: Use PHP ${{ env.PHP_VERSION }} uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: ${{ env.PHP_VERSION }} - name: Install dependencies run: composer update --no-progress --optimize-autoloader - - name: Run unit tests - env: - XDEBUG_MODE: coverage - run: composer test - - - name: Run mutation tests - run: composer test-mutation + - name: Run tests + run: composer tests diff --git a/Makefile b/Makefile index 1b3026e..81f8617 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,17 @@ -DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.2 +ifeq ($(OS),Windows_NT) + PWD := $(shell cd) +else + PWD := $(shell pwd -L) +endif + +ARCH := $(shell uname -m) +PLATFORM := + +ifeq ($(ARCH),arm64) + PLATFORM := --platform=linux/amd64 +endif + +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.3 .PHONY: configure test test-file test-no-coverage review show-reports clean @@ -9,7 +22,7 @@ test: @${DOCKER_RUN} composer tests test-file: - @${DOCKER_RUN} composer tests-file-no-coverage ${FILE} + @${DOCKER_RUN} composer test-file ${FILE} test-no-coverage: @${DOCKER_RUN} composer tests-no-coverage @@ -22,4 +35,4 @@ show-reports: clean: @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache + @rm -rf report vendor .phpunit.cache .lock diff --git a/README.md b/README.md index 8dc4847..ca3f60b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) + * [Using the status code](#status_code) + * [Creating a response](#response) * [License](#license) * [Contributing](#contributing) @@ -12,7 +14,10 @@ ## Overview -Common implementations for HTTP protocol. +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.
@@ -26,46 +31,95 @@ composer require tiny-blocks/http ## How to use -The library exposes concrete implementations for the HTTP protocol, such as status codes, methods, etc. - -### Using the HttpCode - -The library exposes a concrete implementation through the `HttpCode` enum. You can get the status codes, and their -corresponding messages. - -```php -$httpCode = HttpCode::CREATED; - -$httpCode->name; # CREATED -$httpCode->value; # 201 -$httpCode->message(); # 201 Created -``` - -### Using the HttpMethod - -The library exposes a concrete implementation via the `HttpMethod` enum. You can get a set of HTTP methods. - -```php -$method = HttpMethod::GET; - -$method->name; # GET -$method->value; # GET -``` - -### Using the HttpResponse - -The library exposes a concrete implementation for HTTP responses via the `HttpResponse` class. Responses are of the -[ResponseInterface](https://github.com/php-fig/http-message/blob/master/src/ResponseInterface.php) type, according to -the specifications defined in [PSR-7](https://www.php-fig.org/psr/psr-7). - -```php -$data = new Xyz(value: 10); -$response = HttpResponse::ok(data: $data); - -$response->getStatusCode(); # 200 -$response->getReasonPhrase(); # 200 OK -$response->getBody()->getContents(); # {"value":10} -``` +The library exposes interfaces like `Headers` and concrete implementations like `Response`, `ContentType`, and others, +which facilitate construction. + +
+ +### 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->message(); # 200 OK + Code::IM_A_TEAPOT->message(); # 418 I'm a teapot + Code::INTERNAL_SERVER_ERROR->message(); # 500 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 + ``` + +
+ +### 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 +`ResponseInterface` from the PSR, ensuring compatibility with any framework that adheres +to the [PSR-7](https://www.php-fig.org/psr/psr-7) standard. + +- **Creating a response with a body**: To create an HTTP response, you can pass any type of data as the body. + Optionally, you can also specify one or more headers. If no headers are provided, the response will default to + `application/json` content type. + + ```php + use TinyBlocks\Http\Response; + + Response::ok(body: ['message' => 'Resource created successfully.']); + ``` + +- **Creating a response with a body and custom headers**: You can also add custom headers to the response. For instance, + if you want to specify a custom content type or any other header, you can pass the headers as additional arguments. + + ```php + use TinyBlocks\Http\Response; + use TinyBlocks\Http\ContentType; + use TinyBlocks\Http\CacheControl; + use TinyBlocks\Http\ResponseCacheDirectives; + + $body = 'This is a plain text response'; + + $contentType = ContentType::textPlain(); + + $cacheControl = CacheControl::fromResponseDirectives( + maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), + staleIfError: ResponseCacheDirectives::staleIfError() + ); + + Response::ok($body, $contentType, $cacheControl) + ->withHeader(name: 'X-ID', value: 100) + ->withHeader(name: 'X-NAME', value: 'Xpto'); + ```
diff --git a/composer.json b/composer.json index 95ec214..795d6e1 100644 --- a/composer.json +++ b/composer.json @@ -9,11 +9,14 @@ "keywords": [ "psr", "http", + "psr-7", + "psr-15", + "request", + "response", "http-code", "tiny-blocks", "http-status", - "http-methods", - "http-response" + "http-methods" ], "authors": [ { @@ -42,29 +45,31 @@ } }, "require": { - "php": "^8.2", - "tiny-blocks/serializer": "^3", + "php": "^8.3", "psr/http-message": "^1.1", + "tiny-blocks/serializer": "^3", "ext-mbstring": "*" }, "require-dev": { + "slim/psr7": "^1.7", + "slim/slim": "^4.14", "phpmd/phpmd": "^2.15", - "phpunit/phpunit": "^11", "phpstan/phpstan": "^1", - "infection/infection": "^0.29", - "squizlabs/php_codesniffer": "^3.10" + "phpunit/phpunit": "^11", + "infection/infection": "^0", + "squizlabs/php_codesniffer": "^3.11" }, "suggest": { "ext-mbstring": "Provides multibyte-specific string functions that help us deal with multibyte encodings in PHP." }, "scripts": { + "test": "phpunit --configuration phpunit.xml tests", "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", - "phpmd": "phpmd ./src text phpmd.xml --suffixes php --exclude /src/HttpCode.php --exclude /src/Internal/Response --ignore-violations-on-exit", + "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit", "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress", - "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests", - "test-mutation": "infection --only-covered --logger-html=report/coverage/mutation-report.html --coverage=report/coverage --min-msi=100 --min-covered-msi=100 --threads=4", - "test-no-coverage": "phpunit --no-coverage", - "test-mutation-no-coverage": "infection --only-covered --min-msi=100 --threads=4", + "test-file": "phpunit --configuration phpunit.xml --no-coverage --filter", + "mutation-test": "infection --only-covered --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", + "test-no-coverage": "phpunit --configuration phpunit.xml --no-coverage tests", "review": [ "@phpcs", "@phpmd", @@ -72,13 +77,9 @@ ], "tests": [ "@test", - "@test-mutation" + "@mutation-test" ], "tests-no-coverage": [ - "@test-no-coverage", - "@test-mutation-no-coverage" - ], - "tests-file-no-coverage": [ "@test-no-coverage" ] } diff --git a/infection.json.dist b/infection.json.dist index 739162f..45c49fc 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,25 +1,23 @@ { - "timeout": 10, - "testFramework": "phpunit", + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, "tmpDir": "report/infection/", + "minMsi": 100, + "timeout": 30, "source": { "directories": [ "src" ] }, - "logs": { - "text": "report/infection/logs/infection-text.log", - "summary": "report/infection/logs/infection-summary.log" - }, - "mutators": { - "@default": true, - "CastInt": false, - "CastString": false, - "MatchArmRemoval": false, - "MethodCallRemoval": false - }, "phpUnit": { "configDir": "", "customPath": "./vendor/bin/phpunit" - } + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" } diff --git a/phpmd.xml b/phpmd.xml index cd9072e..87dbbc4 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -53,5 +53,4 @@ - \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ffd0c26..b283c54 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,8 +4,8 @@ parameters: level: 9 tmpDir: report/phpstan ignoreErrors: - - '#function fread expects#' - - '#expects object, mixed given#' - - '#expects resource, resource#' - - '#value type specified in iterable#' + - '#expects#' + - '#should return#' + - '#does not accept#' + - '#type specified in iterable type#' reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index 7f080dd..40c80a2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,6 +6,7 @@ failOnRisky="true" failOnWarning="true" cacheDirectory=".phpunit.cache" + executionOrder="random" beStrictAboutOutputDuringTests="true"> @@ -22,14 +23,15 @@ + + - - + diff --git a/src/CacheControl.php b/src/CacheControl.php new file mode 100644 index 0000000..6f474c7 --- /dev/null +++ b/src/CacheControl.php @@ -0,0 +1,29 @@ + $directive->toString(); + + return new CacheControl(directives: array_map($mapper, $directives)); + } + + public function toArray(): array + { + return ['Cache-Control' => implode(', ', $this->directives)]; + } +} diff --git a/src/Charset.php b/src/Charset.php new file mode 100644 index 0000000..67faa2a --- /dev/null +++ b/src/Charset.php @@ -0,0 +1,24 @@ +value); + } +} diff --git a/src/HttpCode.php b/src/Code.php similarity index 61% rename from src/HttpCode.php rename to src/Code.php index a62397f..856c74b 100644 --- a/src/HttpCode.php +++ b/src/Code.php @@ -11,18 +11,14 @@ * Responses are grouped in five classes: * * Informational (100 – 199) - * * Successful (200 – 299) - * * Redirection (300 – 399) - * * Client error (400 – 499) - * * Server error (500 – 599) * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#information_responses */ -enum HttpCode: int +enum Code: int { # Informational 1xx case CONTINUE = 100; @@ -95,13 +91,20 @@ enum HttpCode: int case NOT_EXTENDED = 510; case NETWORK_AUTHENTICATION_REQUIRED = 511; + /** + * Returns the HTTP status message associated with the enum's code. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + * @return string The formatted message with the status code and name. + */ public function message(): string { - $subject = mb_convert_case($this->name, MB_CASE_TITLE); - - if ($this->value === self::OK->value) { - $subject = $this->name; - } + $subject = match ($this) { + self::OK => $this->name, + self::IM_USED => 'IM Used', + self::IM_A_TEAPOT => "I'm a teapot", + default => mb_convert_case($this->name, MB_CASE_TITLE) + }; $message = str_replace('_', ' ', $subject); $template = '%s %s'; @@ -109,10 +112,38 @@ public function message(): string return sprintf($template, $this->value, $message); } - public static function isHttpCode(int $httpCode): bool + /** + * Determines if the given code is a valid HTTP status code represented by the enum. + * + * @param int $code The HTTP status code to check. + * @return bool True if the code exists in the enum, otherwise false. + */ + public static function isValidCode(int $code): bool { - $mapper = fn(BackedEnum $enum) => $enum->value; + $mapper = fn(BackedEnum $enum): int => $enum->value; - return in_array($httpCode, array_map($mapper, self::cases())); + return in_array($code, array_map($mapper, self::cases())); + } + + /** + * Determines if the given code is in the error range (4xx or 5xx). + * + * @param int $code The HTTP status code to check. + * @return bool True if the code is in the error range (4xx or 5xx), otherwise false. + */ + public static function isErrorCode(int $code): bool + { + return $code >= self::BAD_REQUEST->value && $code <= self::NETWORK_AUTHENTICATION_REQUIRED->value; + } + + /** + * Determines if the given code is in the success range (2xx). + * + * @param int $code The HTTP status code to check. + * @return bool True if the code is in the success range (2xx), otherwise false. + */ + public static function isSuccessCode(int $code): bool + { + return $code >= self::OK->value && $code <= self::IM_USED->value; } } diff --git a/src/ContentType.php b/src/ContentType.php new file mode 100644 index 0000000..e5b3d1d --- /dev/null +++ b/src/ContentType.php @@ -0,0 +1,57 @@ + $this->charset + ? sprintf('%s; %s', $this->mimeType->value, $this->charset->toString()) + : $this->mimeType->value + ]; + } +} diff --git a/src/Headers.php b/src/Headers.php new file mode 100644 index 0000000..b417a4a --- /dev/null +++ b/src/Headers.php @@ -0,0 +1,19 @@ +value; - } -} diff --git a/src/HttpHeaders.php b/src/HttpHeaders.php deleted file mode 100644 index 6ba3258..0000000 --- a/src/HttpHeaders.php +++ /dev/null @@ -1,70 +0,0 @@ -values[$key][] = $value; - - return $this; - } - - public function addFromCode(HttpCode $code): HttpHeaders - { - $this->values['Status'][] = $code->message(); - - return $this; - } - - public function addFromContentType(Header $header): HttpHeaders - { - $this->values[$header->key()][] = $header->value(); - - return $this; - } - - public function removeFrom(string $key): HttpHeaders - { - unset($this->values[$key]); - - return $this; - } - - public function getHeader(string $key): array - { - return $this->values[$key] ?? []; - } - - public function hasNoHeaders(): bool - { - return empty($this->values); - } - - public function hasHeader(string $key): bool - { - return !empty($this->getHeader(key: $key)); - } - - public function toArray(): array - { - return array_map(fn(array $values): array => array_unique($values), $this->values); - } -} diff --git a/src/HttpResponse.php b/src/HttpResponse.php deleted file mode 100644 index 94be96d..0000000 --- a/src/HttpResponse.php +++ /dev/null @@ -1,67 +0,0 @@ -toHeaderValue(value: $maxAgeInWholeSeconds)); + } + + public static function noCache(): static + { + return new self(value: Directives::NO_CACHE->toHeaderValue()); + } + + public static function noStore(): static + { + return new self(value: Directives::NO_STORE->toHeaderValue()); + } + + public static function noTransform(): static + { + return new self(value: Directives::NO_TRANSFORM->toHeaderValue()); + } + + public static function staleIfError(): static + { + return new self(value: Directives::STALE_IF_ERROR->toHeaderValue()); + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/src/Internal/CacheControl/Directives.php b/src/Internal/CacheControl/Directives.php new file mode 100644 index 0000000..c30eb90 --- /dev/null +++ b/src/Internal/CacheControl/Directives.php @@ -0,0 +1,27 @@ + sprintf('%s=%d', $this->value, $value), + default => $this->value + }; + } +} diff --git a/src/Internal/Exceptions/BadMethodCall.php b/src/Internal/Exceptions/BadMethodCall.php index aef2ff9..ad1165a 100644 --- a/src/Internal/Exceptions/BadMethodCall.php +++ b/src/Internal/Exceptions/BadMethodCall.php @@ -11,6 +11,7 @@ final class BadMethodCall extends BadMethodCallException public function __construct(private readonly string $method) { $template = 'Method <%s> cannot be used.'; + parent::__construct(message: sprintf($template, $this->method)); } } diff --git a/src/Internal/Header.php b/src/Internal/Header.php deleted file mode 100644 index c2c8ae6..0000000 --- a/src/Internal/Header.php +++ /dev/null @@ -1,22 +0,0 @@ -hasNoHeaders()) { - $headers = HttpHeaders::build() - ->addFromCode(code: $code) - ->addFromContentType(header: HttpContentType::APPLICATION_JSON); - } - - return new Response(code: $code, body: StreamFactory::from(data: $data), headers: $headers); - } - - public function withBody(StreamInterface $body): MessageInterface - { - throw new BadMethodCall(method: __METHOD__); - } - - public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface - { - throw new BadMethodCall(method: __METHOD__); - } - - public function withHeader(string $name, mixed $value): MessageInterface - { - $this->headers->addFrom(key: $name, value: $value); - - return $this; - } - - public function withoutHeader(string $name): MessageInterface - { - $this->headers->removeFrom(key: $name); - - return $this; - } - - public function withAddedHeader(string $name, mixed $value): MessageInterface - { - throw new BadMethodCall(method: __METHOD__); - } - - public function withProtocolVersion(string $version): MessageInterface - { - throw new BadMethodCall(method: __METHOD__); - } - - public function getProtocolVersion(): string - { - return '1.1'; - } - - public function getHeaders(): array - { - return $this->headers->toArray(); - } - - public function hasHeader(string $name): bool - { - return $this->headers->hasHeader(key: $name); - } - - public function getHeader(string $name): array - { - return $this->headers->getHeader(key: $name); - } - - public function getHeaderLine(string $name): string - { - return implode(', ', $this->headers->getHeader(key: $name)); - } - - public function getBody(): StreamInterface - { - return $this->body; - } - - public function getStatusCode(): int - { - return $this->code->value; - } - - public function getReasonPhrase(): string - { - return $this->code->message(); - } -} diff --git a/src/Internal/Response/InternalResponse.php b/src/Internal/Response/InternalResponse.php new file mode 100644 index 0000000..66b92f0 --- /dev/null +++ b/src/Internal/Response/InternalResponse.php @@ -0,0 +1,150 @@ +write(), + code: $code, + headers: ResponseHeaders::fromOrDefault(...$headers), + protocolVersion: ProtocolVersion::default() + ); + } + + public static function createWithoutBody(Code $code, Headers ...$headers): ResponseInterface + { + return new InternalResponse( + body: StreamFactory::fromEmptyBody()->write(), + code: $code, + headers: ResponseHeaders::fromOrDefault(...$headers), + protocolVersion: ProtocolVersion::default() + ); + } + + public function withBody(StreamInterface $body): MessageInterface + { + return new InternalResponse( + body: $body, + code: $this->code, + headers: $this->headers, + protocolVersion: $this->protocolVersion + ); + } + + public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface + { + throw new BadMethodCall(method: __FUNCTION__); + } + + public function withHeader(string $name, $value): MessageInterface + { + $headers = ResponseHeaders::fromOrDefault( + $this->headers, + ResponseHeaders::fromNameAndValue(name: $name, value: $value) + ); + + return new InternalResponse( + body: $this->body, + code: $this->code, + headers: $headers, + protocolVersion: $this->protocolVersion + ); + } + + public function withoutHeader(string $name): MessageInterface + { + $headers = $this->headers->removeByName(name: $name); + + return new InternalResponse( + body: $this->body, + code: $this->code, + headers: $headers, + protocolVersion: $this->protocolVersion + ); + } + + public function withAddedHeader(string $name, $value): MessageInterface + { + $headers = ResponseHeaders::fromNameAndValue(name: $name, value: $value); + + return new InternalResponse( + body: $this->body, + code: $this->code, + headers: $headers, + protocolVersion: $this->protocolVersion + ); + } + + public function withProtocolVersion(string $version): MessageInterface + { + $protocolVersion = ProtocolVersion::from(version: $version); + + return new InternalResponse( + body: $this->body, + code: $this->code, + headers: $this->headers, + protocolVersion: $protocolVersion + ); + } + + public function hasHeader(string $name): bool + { + return $this->headers->hasHeader(name: $name); + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function getHeader(string $name): array + { + return $this->headers->getByName(name: $name); + } + + public function getHeaders(): array + { + return $this->headers->toArray(); + } + + public function getStatusCode(): int + { + return $this->code->value; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->headers->getByName(name: $name)); + } + + public function getReasonPhrase(): string + { + return $this->code->message(); + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion->version; + } +} diff --git a/src/Internal/Response/ProtocolVersion.php b/src/Internal/Response/ProtocolVersion.php new file mode 100644 index 0000000..2767f9b --- /dev/null +++ b/src/Internal/Response/ProtocolVersion.php @@ -0,0 +1,24 @@ +toArray()] + : array_map(fn(Headers $header) => $header->toArray(), $headers); + + return new ResponseHeaders(headers: array_merge([], ...$mappedHeaders)); + } + + public static function fromNameAndValue(string $name, mixed $value): ResponseHeaders + { + return new ResponseHeaders(headers: [$name => $value]); + } + + public function getByName(string $name): array + { + $headers = array_change_key_case($this->headers); + $value = $headers[strtolower($name)] ?? []; + + return is_array($value) ? $value : [$value]; + } + + public function hasHeader(string $name): bool + { + return !empty($this->getByName(name: $name)); + } + + public function removeByName(string $name): ResponseHeaders + { + $headers = $this->headers; + $existingHeader = $this->getByName(name: $name); + + if (!empty($existingHeader)) { + unset($headers[$name]); + } + + return new ResponseHeaders(headers: $headers); + } + + + public function toArray(): array + { + return $this->headers; + } +} diff --git a/src/Internal/Stream/Stream.php b/src/Internal/Response/Stream/Stream.php similarity index 90% rename from src/Internal/Stream/Stream.php rename to src/Internal/Response/Stream/Stream.php index d0337b1..2954aa6 100644 --- a/src/Internal/Stream/Stream.php +++ b/src/Internal/Response/Stream/Stream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Response\Stream; use Psr\Http\Message\StreamInterface; use TinyBlocks\Http\Internal\Exceptions\InvalidResource; @@ -13,7 +13,10 @@ final class Stream implements StreamInterface { + private const int OFFSET_ZERO = 0; + private string $content = ''; + private bool $contentFetched = false; /** @@ -63,7 +66,7 @@ public function getSize(): ?int $size = fstat($this->resource); - return is_array($size) ? (int)$size['size'] : null; + return is_array($size) ? $size['size'] : null; } public function tell(): int @@ -72,7 +75,7 @@ public function tell(): int throw new MissingResourceStream(); } - return (int)ftell($this->resource); + return ftell($this->resource); } public function eof(): bool @@ -91,7 +94,7 @@ public function seek(int $offset, int $whence = SEEK_SET): void public function rewind(): void { - $this->seek(offset: 0); + $this->seek(offset: self::OFFSET_ZERO); } public function read(int $length): string @@ -100,7 +103,7 @@ public function read(int $length): string throw new NonReadableStream(); } - return (string)fread($this->resource, $length); + return fread($this->resource, $length); } public function write(string $string): int @@ -109,7 +112,7 @@ public function write(string $string): int throw new NonWritableStream(); } - return (int)fwrite($this->resource, $string); + return fwrite($this->resource, $string); } public function isReadable(): bool @@ -150,7 +153,7 @@ public function getContents(): string } if (!$this->contentFetched) { - $this->content = (string)stream_get_contents($this->resource); + $this->content = stream_get_contents($this->resource); $this->contentFetched = true; } diff --git a/src/Internal/Response/Stream/StreamFactory.php b/src/Internal/Response/Stream/StreamFactory.php new file mode 100644 index 0000000..947e124 --- /dev/null +++ b/src/Internal/Response/Stream/StreamFactory.php @@ -0,0 +1,53 @@ +stream = Stream::from(resource: fopen('php://memory', 'wb+')); + } + + public static function fromBody(mixed $body): StreamFactory + { + $dataToWrite = match (true) { + is_a($body, Serializer::class) => $body->toJson(), + is_a($body, BackedEnum::class) => self::toJsonFrom(body: $body->value), + is_a($body, UnitEnum::class) => $body->name, + is_object($body) => self::toJsonFrom(body: get_object_vars($body)), + is_string($body) => $body, + is_scalar($body) || is_array($body) => self::toJsonFrom(body: $body), + default => '' + }; + + return new StreamFactory(body: $dataToWrite); + } + + public static function fromEmptyBody(): StreamFactory + { + return new StreamFactory(body: ''); + } + + public function write(): StreamInterface + { + $this->stream->write(string: $this->body); + $this->stream->rewind(); + + return $this->stream; + } + + private static function toJsonFrom(mixed $body): string + { + return json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + } +} diff --git a/src/Internal/Stream/StreamMetaData.php b/src/Internal/Response/Stream/StreamMetaData.php similarity index 94% rename from src/Internal/Stream/StreamMetaData.php rename to src/Internal/Response/Stream/StreamMetaData.php index 7a85248..e99ce2e 100644 --- a/src/Internal/Stream/StreamMetaData.php +++ b/src/Internal/Response/Stream/StreamMetaData.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Response\Stream; final readonly class StreamMetaData { diff --git a/src/Internal/Stream/StreamFactory.php b/src/Internal/Stream/StreamFactory.php deleted file mode 100644 index 52695cc..0000000 --- a/src/Internal/Stream/StreamFactory.php +++ /dev/null @@ -1,28 +0,0 @@ - $data->toJson(), - is_object($data) => (string)json_encode(get_object_vars($data)), - is_scalar($data) || is_array($data) => (string)json_encode($data, JSON_PRESERVE_ZERO_FRACTION), - default => '' - }; - - $stream->write(string: $dataToWrite); - $stream->rewind(); - - return $stream; - } -} diff --git a/src/HttpMethod.php b/src/Method.php similarity index 95% rename from src/HttpMethod.php rename to src/Method.php index 19ff5a3..77387a1 100644 --- a/src/HttpMethod.php +++ b/src/Method.php @@ -9,7 +9,7 @@ * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods */ -enum HttpMethod: string +enum Method: string { case GET = 'GET'; case PUT = 'PUT'; diff --git a/src/MimeType.php b/src/MimeType.php new file mode 100644 index 0000000..e6a6fcd --- /dev/null +++ b/src/MimeType.php @@ -0,0 +1,21 @@ +toHeaderValue()); + } + + public static function proxyRevalidate(): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::PROXY_REVALIDATE->toHeaderValue()); + } +} diff --git a/src/Responses.php b/src/Responses.php new file mode 100644 index 0000000..1ac5aac --- /dev/null +++ b/src/Responses.php @@ -0,0 +1,95 @@ +response; + } +} diff --git a/tests/Drivers/Middleware.php b/tests/Drivers/Middleware.php new file mode 100644 index 0000000..92a7d1a --- /dev/null +++ b/tests/Drivers/Middleware.php @@ -0,0 +1,18 @@ +handle(request: $request); + } +} diff --git a/tests/Drivers/Slim/RequestFactory.php b/tests/Drivers/Slim/RequestFactory.php new file mode 100644 index 0000000..566ee8d --- /dev/null +++ b/tests/Drivers/Slim/RequestFactory.php @@ -0,0 +1,41 @@ +createUri() + ->withScheme('https') + ->withHost('localhost') + ->withPath('/'); + + /** @var resource $stream */ + $stream = fopen('php://temp', 'r+'); + + fwrite($stream, json_encode($payload)); + rewind($stream); + + $body = new Stream($stream); + $headers = new Headers(['Content-Type' => 'application/json']); + + return new SlimRequest( + method: 'POST', + uri: $uri, + headers: $headers, + cookies: [], + serverParams: [], + body: $body + ); + } +} diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php new file mode 100644 index 0000000..8cd39ae --- /dev/null +++ b/tests/Drivers/Slim/SlimTest.php @@ -0,0 +1,56 @@ +middleware = new Middleware(); + } + + public function testSuccessfulResponse(): void + { + /** @Given valid data */ + $payload = ['id' => PHP_INT_MAX, 'name' => 'Drakkor Emberclaw']; + + /** @And this data is used to create a request */ + $request = RequestFactory::postFrom(payload: $payload); + + /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And a Cache-Control header is set with no-cache directive */ + $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); + + /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); + + /** @When the request is processed by the handler */ + $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); + + /** @Then the response status should indicate success */ + self::assertSame(Code::OK->value, $actual->getStatusCode()); + + /** @And the response body should match the expected body */ + self::assertSame($response->getBody()->getContents(), $actual->getBody()->getContents()); + + /** @And the response headers should match the expected headers */ + self::assertSame($response->getHeaders(), $actual->getHeaders()); + } +} diff --git a/tests/HttpCodeTest.php b/tests/HttpCodeTest.php deleted file mode 100644 index e5c6c3a..0000000 --- a/tests/HttpCodeTest.php +++ /dev/null @@ -1,97 +0,0 @@ -message(); - - /** @Then the message should match the expected string */ - self::assertEquals($expected, $actual); - } - - #[DataProvider('httpCodesDataProvider')] - public function testIsHttpCode(int $httpCode, bool $expected): void - { - /** @Given an integer representing an HTTP code */ - /** @When checking if it is a valid HTTP code */ - $actual = HttpCode::isHttpCode(httpCode: $httpCode); - - /** @Then the result should match the expected boolean */ - self::assertEquals($expected, $actual); - } - - public static function messagesDataProvider(): array - { - return [ - 'OK message' => [ - 'httpCode' => HttpCode::OK, - 'expected' => '200 OK' - ], - 'Created message' => [ - 'httpCode' => HttpCode::CREATED, - 'expected' => '201 Created' - ], - 'Continue message' => [ - 'httpCode' => HttpCode::CONTINUE, - 'expected' => '100 Continue' - ], - 'Permanent Redirect message' => [ - 'httpCode' => HttpCode::PERMANENT_REDIRECT, - 'expected' => '308 Permanent Redirect' - ], - 'Internal Server Error message' => [ - 'httpCode' => HttpCode::INTERNAL_SERVER_ERROR, - 'expected' => '500 Internal Server Error' - ], - 'Non Authoritative Information message' => [ - 'httpCode' => HttpCode::NON_AUTHORITATIVE_INFORMATION, - 'expected' => '203 Non Authoritative Information' - ], - 'Proxy Authentication Required message' => [ - 'httpCode' => HttpCode::PROXY_AUTHENTICATION_REQUIRED, - 'expected' => '407 Proxy Authentication Required' - ], - ]; - } - - public static function httpCodesDataProvider(): array - { - return [ - 'Invalid code 0' => [ - 'httpCode' => 0, - 'expected' => false - ], - 'Invalid code -1' => [ - 'httpCode' => -1, - 'expected' => false - ], - 'Invalid code 1054' => [ - 'httpCode' => 1054, - 'expected' => false - ], - 'Valid code 200 OK' => [ - 'httpCode' => HttpCode::OK->value, - 'expected' => true - ], - 'Valid code 100 Continue' => [ - 'httpCode' => HttpCode::CONTINUE->value, - 'expected' => true - ], - 'Valid code 500 Internal Server Error' => [ - 'httpCode' => HttpCode::INTERNAL_SERVER_ERROR->value, - 'expected' => true - ] - ]; - } -} diff --git a/tests/HttpResponseTest.php b/tests/HttpResponseTest.php deleted file mode 100644 index 2c79057..0000000 --- a/tests/HttpResponseTest.php +++ /dev/null @@ -1,176 +0,0 @@ -getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::OK->value, $response->getStatusCode()); - self::assertEquals(HttpCode::OK->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::OK), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseCreated(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Created */ - $response = HttpResponse::created(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::CREATED->value, $response->getStatusCode()); - self::assertEquals(HttpCode::CREATED->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::CREATED), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseAccepted(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Accepted */ - $response = HttpResponse::accepted(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::ACCEPTED->value, $response->getStatusCode()); - self::assertEquals(HttpCode::ACCEPTED->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::ACCEPTED), $response->getHeaders()); - } - - public function testResponseNoContent(): void - { - /** @Given a valid HTTP response with status No Content */ - $response = HttpResponse::noContent(); - - /** @Then verify that the response body is empty and headers are correct */ - self::assertEquals('', $response->getBody()->__toString()); - self::assertEquals('', $response->getBody()->getContents()); - self::assertEquals(HttpCode::NO_CONTENT->value, $response->getStatusCode()); - self::assertEquals(HttpCode::NO_CONTENT->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::NO_CONTENT), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseBadRequest(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Bad Request */ - $response = HttpResponse::badRequest(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::BAD_REQUEST->value, $response->getStatusCode()); - self::assertEquals(HttpCode::BAD_REQUEST->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::BAD_REQUEST), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseNotFound(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Not Found */ - $response = HttpResponse::notFound(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::NOT_FOUND->value, $response->getStatusCode()); - self::assertEquals(HttpCode::NOT_FOUND->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::NOT_FOUND), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseConflict(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Conflict */ - $response = HttpResponse::conflict(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::CONFLICT->value, $response->getStatusCode()); - self::assertEquals(HttpCode::CONFLICT->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::CONFLICT), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseUnprocessableEntity(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Unprocessable Entity */ - $response = HttpResponse::unprocessableEntity(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::UNPROCESSABLE_ENTITY->value, $response->getStatusCode()); - self::assertEquals(HttpCode::UNPROCESSABLE_ENTITY->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::UNPROCESSABLE_ENTITY), $response->getHeaders()); - } - - #[DataProvider('providerData')] - public function testResponseInternalServerError(mixed $data, mixed $expected): void - { - /** @Given a valid HTTP response with status Internal Server Error */ - $response = HttpResponse::internalServerError(data: $data); - - /** @Then verify that the response body and headers are correct */ - self::assertEquals($expected, $response->getBody()->__toString()); - self::assertEquals($expected, $response->getBody()->getContents()); - self::assertEquals(HttpCode::INTERNAL_SERVER_ERROR->value, $response->getStatusCode()); - self::assertEquals(HttpCode::INTERNAL_SERVER_ERROR->message(), $response->getReasonPhrase()); - self::assertEquals($this->defaultHeaderFrom(code: HttpCode::INTERNAL_SERVER_ERROR), $response->getHeaders()); - } - - public static function providerData(): array - { - return [ - 'Null value' => [ - 'data' => null, - 'expected' => null - ], - 'Empty string' => [ - 'data' => '', - 'expected' => '""' - ], - 'Boolean true value' => [ - 'data' => true, - 'expected' => 'true' - ], - 'Large integer value' => [ - 'data' => 10000000000, - 'expected' => '10000000000' - ], - 'Xyz object serialization' => [ - 'data' => new Xyz(value: 10), - 'expected' => '{"value":10}' - ], - 'Xpto object serialization with toJson' => [ - 'data' => new Xpto(value: 9.99), - 'expected' => (new Xpto(value: 9.99))->toJson() - ] - ]; - } - - private function defaultHeaderFrom(HttpCode $code): array - { - return [ - 'Status' => [$code->message()], - 'Content-Type' => [HttpContentType::APPLICATION_JSON->value] - ]; - } -} diff --git a/tests/Internal/HttpHeadersTest.php b/tests/Internal/HttpHeadersTest.php deleted file mode 100644 index a467aa8..0000000 --- a/tests/Internal/HttpHeadersTest.php +++ /dev/null @@ -1,75 +0,0 @@ -addFrom(key: 'X-Custom-Header', value: 'value1') - ->addFrom(key: 'X-Custom-Header', value: 'value2') - ->removeFrom(key: 'X-Custom-Header'); - - /** @Then all headers should be removed */ - self::assertTrue($actual->hasNoHeaders()); - self::assertFalse($actual->hasHeader(key: 'X-Custom-Header')); - } - - public function testAddFromCode(): void - { - /** @Given HttpHeaders */ - $actual = HttpHeaders::build()->addFromCode(code: HttpCode::OK); - - /** @Then the Status header should be added with the correct value */ - self::assertEquals(['Status' => [HttpCode::OK->message()]], $actual->toArray()); - } - - public function testAddFromContentType(): void - { - /** @Given HttpHeaders */ - $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::APPLICATION_JSON); - - /** @When adding a Content-Type header */ - $actual = $headers->toArray(); - - /** @Then the Content-Type header should match the expected value */ - self::assertEquals(['Content-Type' => [HttpContentType::APPLICATION_JSON->value]], $actual); - } - - public function testGetHeader(): void - { - /** @Given HttpHeaders with duplicate headers */ - $headers = HttpHeaders::build() - ->addFrom(key: 'X-Custom-Header', value: 'value1') - ->addFrom(key: 'X-Custom-Header', value: 'value2'); - - /** @When retrieving the header */ - $actual = $headers->getHeader(key: 'X-Custom-Header'); - - /** @Then the header values should match the expected array */ - self::assertEquals(['value1', 'value2'], $actual); - } - - public function testToArrayWithNonUniqueValues(): void - { - /** @Given HttpHeaders with duplicate values for a single header */ - $headers = HttpHeaders::build() - ->addFrom(key: 'X-Custom-Header', value: 'value1') - ->addFrom(key: 'X-Custom-Header', value: 'value1'); - - /** @When converting the headers to an array */ - $actual = $headers->toArray(); - - /** @Then duplicate values should be collapsed into a single entry */ - self::assertEquals(['X-Custom-Header' => ['value1']], $actual); - } -} diff --git a/tests/Internal/Response/Stream/StreamFactoryTest.php b/tests/Internal/Response/Stream/StreamFactoryTest.php new file mode 100644 index 0000000..4afe47f --- /dev/null +++ b/tests/Internal/Response/Stream/StreamFactoryTest.php @@ -0,0 +1,27 @@ +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/StreamTest.php b/tests/Internal/Response/Stream/StreamTest.php similarity index 92% rename from tests/Internal/Stream/StreamTest.php rename to tests/Internal/Response/Stream/StreamTest.php index f298729..5aca8dc 100644 --- a/tests/Internal/Stream/StreamTest.php +++ b/tests/Internal/Response/Stream/StreamTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Response\Stream; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -15,6 +15,7 @@ final class StreamTest extends TestCase { private mixed $resource; + private ?string $temporary; protected function setUp(): void @@ -37,13 +38,14 @@ public function testGetMetadata(): void /** @When retrieving metadata */ $actual = $stream->getMetadata(); - $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); /** @Then the metadata should match the expected values */ - self::assertEquals($expected['uri'], $actual['uri']); - self::assertEquals($expected['mode'], $actual['mode']); - self::assertEquals($expected['seekable'], $actual['seekable']); - self::assertEquals($expected['streamType'], $actual['streamType']); + $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); + + self::assertSame($expected['uri'], $actual['uri']); + self::assertSame($expected['mode'], $actual['mode']); + self::assertSame($expected['seekable'], $actual['seekable']); + self::assertSame($expected['streamType'], $actual['streamType']); } public function testCloseWithoutResource(): void @@ -91,8 +93,8 @@ public function testSeekMovesCursorPosition(): void /** @Then the cursor position should be updated correctly */ self::assertTrue($stream->isWritable()); self::assertTrue($stream->isSeekable()); - self::assertEquals(7, $tellAfterFirstSeek); - self::assertEquals(13, $stream->tell()); + self::assertSame(7, $tellAfterFirstSeek); + self::assertSame(13, $stream->tell()); } public function testGetSizeReturnsCorrectSize(): void @@ -105,8 +107,8 @@ public function testGetSizeReturnsCorrectSize(): void $stream->write(string: 'Hello, world!'); /** @Then the size should be updated correctly */ - self::assertEquals(0, $sizeBeforeWrite); - self::assertEquals(13, $stream->getSize()); + self::assertSame(0, $sizeBeforeWrite); + self::assertSame(13, $stream->getSize()); } public function testIsWritableForCreateMode(): void @@ -128,7 +130,7 @@ public function testIsWritableForVariousModes(string $mode, bool $expected): voi $stream = Stream::from(resource: fopen('php://memory', $mode)); /** @Then check if the stream is writable based on the mode */ - self::assertEquals($expected, $stream->isWritable()); + self::assertSame($expected, $stream->isWritable()); } public function testRewindResetsCursorPosition(): void @@ -142,7 +144,7 @@ public function testRewindResetsCursorPosition(): void $stream->rewind(); /** @Then the cursor position should be reset to the beginning */ - self::assertEquals(0, $stream->tell()); + self::assertSame(0, $stream->tell()); } public function testEofReturnsTrueAtEndOfStream(): void @@ -182,7 +184,7 @@ public function testToStringRewindsStreamIfNotSeekable(): void $stream->write(string: 'Hello, world!'); /** @Then the content should match the written data */ - self::assertEquals('Hello, world!', (string)$stream); + self::assertSame('Hello, world!', (string)$stream); } public function testGetSizeReturnsNullWhenWithoutResource(): void @@ -241,7 +243,7 @@ public function testExceptionWhenInvalidResourceProvided(): void $this->expectException(InvalidResource::class); $this->expectExceptionMessage('The provided value is not a valid resource.'); - /** @When calling the from method with an invalid resource */ + /** @When calling from method with an invalid resource */ Stream::from(resource: $resource); } diff --git a/tests/Internal/ResponseTest.php b/tests/Internal/ResponseTest.php deleted file mode 100644 index 1d0a6d2..0000000 --- a/tests/Internal/ResponseTest.php +++ /dev/null @@ -1,146 +0,0 @@ - [HttpCode::OK->message()], - 'Content-Type' => [HttpContentType::APPLICATION_JSON->value] - ], $response->getHeaders()); - } - - public function testGetProtocolVersion(): void - { - /** @Given a Response */ - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @Then the protocol version should be 1.1 */ - self::assertEquals('1.1', $response->getProtocolVersion()); - } - - public function testGetHeaders(): void - { - /** @Given a Response with specific headers */ - $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::APPLICATION_JSON); - $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); - - /** @Then the Response should return the correct headers */ - self::assertEquals($headers->toArray(), $response->getHeaders()); - self::assertEquals([HttpContentType::APPLICATION_JSON->value], $response->getHeader(name: 'Content-Type')); - } - - public function testHasHeader(): void - { - /** @Given a Response with a specific Content-Type header */ - $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::TEXT_PLAIN); - $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); - - /** @Then the Response should correctly indicate that it has the Content-Type header */ - self::assertTrue($response->hasHeader(name: 'Content-Type')); - self::assertEquals([HttpContentType::TEXT_PLAIN->value], $response->getHeader(name: 'Content-Type')); - } - - public function testGetHeaderLine(): void - { - /** @Given a Response with a specific Content-Type header */ - $headers = HttpHeaders::build()->addFromContentType(header: HttpContentType::APPLICATION_JSON); - $response = Response::from(code: HttpCode::OK, data: [], headers: $headers); - - /** @Then the header line should match the expected value */ - self::assertEquals(HttpContentType::APPLICATION_JSON->value, $response->getHeaderLine(name: 'Content-Type')); - } - - public function testWithHeader(): void - { - /** @Given a Response */ - $value = '2850bf62-8383-4e9f-b237-d41247a1df3b'; - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @When adding a new header */ - $response->withHeader(name: 'Token', value: $value); - - /** @Then the new header should be included in the Response */ - self::assertEquals([$value], $response->getHeader(name: 'Token')); - } - - public function testWithoutHeader(): void - { - /** @Given a Response with default headers */ - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @When removing the Status header */ - $response->withoutHeader(name: 'Status'); - - /** @Then the Status header should be empty and Content-Type should remain intact */ - self::assertEmpty($response->getHeader(name: 'Status')); - self::assertEquals([HttpContentType::APPLICATION_JSON->value], $response->getHeader(name: 'Content-Type')); - } - - public function testExceptionWhenBadMethodCallOnWithBody(): void - { - /** @Given a Response */ - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @Then a BadMethodCall exception should be thrown when calling withBody */ - self::expectException(BadMethodCall::class); - self::expectExceptionMessage('Method cannot be used.'); - - /** @When attempting to call withBody */ - $response->withBody(body: StreamFactory::from(data: [])); - } - - public function testExceptionWhenBadMethodCallOnWithStatus(): void - { - /** @Given a Response */ - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @Then a BadMethodCall exception should be thrown when calling withStatus */ - self::expectException(BadMethodCall::class); - self::expectExceptionMessage('Method cannot be used.'); - - /** @When attempting to call withStatus */ - $response->withStatus(code: HttpCode::OK->value); - } - - public function testExceptionWhenBadMethodCallOnWithAddedHeader(): void - { - /** @Given a Response */ - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @Then a BadMethodCall exception should be thrown when calling withAddedHeader */ - self::expectException(BadMethodCall::class); - self::expectExceptionMessage('Method cannot be used.'); - - /** @When attempting to call withAddedHeader */ - $response->withAddedHeader(name: '', value: ''); - } - - public function testExceptionWhenBadMethodCallOnWithProtocolVersion(): void - { - /** @Given a Response */ - $response = Response::from(code: HttpCode::OK, data: [], headers: null); - - /** @Then a BadMethodCall exception should be thrown when calling withProtocolVersion */ - self::expectException(BadMethodCall::class); - self::expectExceptionMessage('Method cannot be used.'); - - /** @When attempting to call withProtocolVersion */ - $response->withProtocolVersion(version: ''); - } -} diff --git a/tests/Models/Amount.php b/tests/Models/Amount.php new file mode 100644 index 0000000..d24cbd1 --- /dev/null +++ b/tests/Models/Amount.php @@ -0,0 +1,12 @@ +elements = is_array($elements) ? $elements : iterator_to_array($elements); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->elements); + } +} diff --git a/tests/Models/Status.php b/tests/Models/Status.php new file mode 100644 index 0000000..e33d236 --- /dev/null +++ b/tests/Models/Status.php @@ -0,0 +1,10 @@ +message(); + + /** @Then the message should match the expected string */ + self::assertSame($expected, $actual); + } + + #[DataProvider('codesDataProvider')] + public function testIsHttpCode(int $code, bool $expected): void + { + /** @Given an integer representing an HTTP code */ + /** @When checking if it is a valid HTTP code */ + $actual = Code::isValidCode(code: $code); + + /** @Then the result should match the expected boolean */ + self::assertSame($expected, $actual); + } + + #[DataProvider('errorCodesDataProvider')] + public function testIsErrorCode(int $code, bool $expected): void + { + /** @Given an HTTP status code */ + /** @When checking if it is an error code (4xx or 5xx) */ + $actual = Code::isErrorCode(code: $code); + + /** @Then the result should match the expected boolean */ + self::assertSame($expected, $actual); + } + + #[DataProvider('successCodesDataProvider')] + public function testIsSuccessCode(int $code, bool $expected): void + { + /** @Given an HTTP status code */ + /** @When checking if it is a success code (2xx) */ + $actual = Code::isSuccessCode(code: $code); + + /** @Then the result should match the expected boolean */ + self::assertSame($expected, $actual); + } + + public static function messagesDataProvider(): array + { + return [ + 'OK message' => [ + 'code' => Code::OK, + 'expected' => '200 OK' + ], + 'Created message' => [ + 'code' => Code::CREATED, + 'expected' => '201 Created' + ], + 'IM Used message' => [ + 'code' => Code::IM_USED, + 'expected' => '226 IM Used' + ], + 'Continue message' => [ + 'code' => Code::CONTINUE, + 'expected' => '100 Continue' + ], + "I'm a teapot message" => [ + 'code' => Code::IM_A_TEAPOT, + 'expected' => "418 I'm a teapot" + ], + 'Permanent Redirect message' => [ + 'code' => Code::PERMANENT_REDIRECT, + 'expected' => '308 Permanent Redirect' + ], + 'Internal Server Error message' => [ + 'code' => Code::INTERNAL_SERVER_ERROR, + 'expected' => '500 Internal Server Error' + ], + 'Non Authoritative Information message' => [ + 'code' => Code::NON_AUTHORITATIVE_INFORMATION, + 'expected' => '203 Non Authoritative Information' + ], + 'Proxy Authentication Required message' => [ + 'code' => Code::PROXY_AUTHENTICATION_REQUIRED, + 'expected' => '407 Proxy Authentication Required' + ], + 'Network Authentication Required message' => [ + 'code' => Code::NETWORK_AUTHENTICATION_REQUIRED, + 'expected' => '511 Network Authentication Required' + ] + ]; + } + + public static function codesDataProvider(): array + { + return [ + 'Invalid code 0' => [ + 'code' => 0, + 'expected' => false + ], + 'Invalid code -1' => [ + 'code' => -1, + 'expected' => false + ], + 'Invalid code 1054' => [ + 'code' => 1054, + 'expected' => false + ], + 'Valid code 200 OK' => [ + 'code' => Code::OK->value, + 'expected' => true + ], + 'Valid code 100 Continue' => [ + 'code' => Code::CONTINUE->value, + 'expected' => true + ], + 'Valid code 500 Internal Server Error' => [ + 'code' => Code::INTERNAL_SERVER_ERROR->value, + 'expected' => true + ] + ]; + } + + public static function errorCodesDataProvider(): array + { + return [ + 'Code 200 OK' => ['code' => 200, 'expected' => false], + 'Code 400 Bad Request' => ['code' => 400, 'expected' => true], + 'Code 500 Internal Server Error' => ['code' => 500, 'expected' => true], + 'Code 511 Network Authentication Required' => ['code' => 511, 'expected' => true] + ]; + } + + public static function successCodesDataProvider(): array + { + return [ + 'Code 200 OK' => ['code' => 200, 'expected' => true], + 'Code 201 Created' => ['code' => 201, 'expected' => true], + 'Code 226 IM Used' => ['code' => 226, 'expected' => true], + 'Code 500 Internal Server Error' => ['code' => 500, 'expected' => false] + ]; + } +} diff --git a/tests/Response/HeadersTest.php b/tests/Response/HeadersTest.php new file mode 100644 index 0000000..ca9c0b2 --- /dev/null +++ b/tests/Response/HeadersTest.php @@ -0,0 +1,208 @@ + 'application/json; charset=utf-8'], $response->getHeaders()); + + /** @When we add custom headers to the response */ + $actual = $response + ->withHeader(name: 'X-ID', value: 100) + ->withHeader(name: 'X-NAME', value: 'Xpto'); + + /** @Then the response should contain the correct headers */ + self::assertSame( + ['Content-Type' => 'application/json; charset=utf-8', 'X-ID' => 100, 'X-NAME' => 'Xpto'], + $actual->getHeaders() + ); + + /** @And when we update the 'X-ID' header with a new value */ + $actual = $actual->withHeader(name: 'X-ID', value: 200); + + /** @Then the response should contain the updated 'X-ID' header value */ + self::assertSame('200', $actual->withAddedHeader(name: 'X-ID', value: 200)->getHeaderLine(name: 'X-ID')); + self::assertSame( + ['Content-Type' => 'application/json; charset=utf-8', 'X-ID' => 200, 'X-NAME' => 'Xpto'], + $actual->getHeaders() + ); + + /** @And when we remove the 'X-NAME' header */ + $actual = $actual->withoutHeader(name: 'X-NAME'); + + /** @Then the response should contain only the 'X-ID' header and the default 'Content-Type' header */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8', 'X-ID' => 200], $actual->getHeaders()); + } + + public function testResponseWithDuplicatedHeader(): void + { + /** @Given an HTTP response with a 'Content-Type' header set to 'application/json; charset=utf-8' */ + $response = Response::noContent(); + + /** @When we add the 'Content-Type' header twice with different values */ + $actual = $response + ->withHeader(name: 'Content-Type', value: 'application/json; charset=utf-8') + ->withHeader(name: 'Content-Type', value: 'application/json; charset=ISO-8859-1'); + + /** @Then the response should contain the latest 'Content-Type' value */ + self::assertSame('application/json; charset=ISO-8859-1', $actual->getHeaderLine(name: 'Content-Type')); + + /** @And the headers should only contain the last 'Content-Type' value */ + self::assertSame(['Content-Type' => 'application/json; charset=ISO-8859-1'], $actual->getHeaders()); + } + + public function testResponseWithCacheControl(): void + { + /** @Given a Cache-Control header with multiple directives */ + $cacheControl = CacheControl::fromResponseDirectives( + maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), + noCache: ResponseCacheDirectives::noCache(), + noStore: ResponseCacheDirectives::noStore(), + noTransform: ResponseCacheDirectives::noTransform(), + staleIfError: ResponseCacheDirectives::staleIfError(), + mustRevalidate: ResponseCacheDirectives::mustRevalidate(), + proxyRevalidate: ResponseCacheDirectives::proxyRevalidate() + ); + + /** @When we create an HTTP response with no content, using the provided Cache-Control header */ + $actual = Response::noContent($cacheControl); + + /** @And the response should include a Cache-Control header */ + self::assertTrue($actual->hasHeader(name: 'Cache-Control')); + + /** @And the Cache-Control header should match the provided directives */ + $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, must-revalidate, proxy-revalidate'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Cache-Control')); + self::assertSame([$expected], $actual->getHeader(name: 'Cache-Control')); + self::assertSame($cacheControl->toArray(), $actual->getHeaders()); + } + + public function testResponseWithContentTypePDF(): void + { + /** @Given the Content-Type header is set to application/pdf */ + $contentType = ContentType::applicationPdf(); + + /** @When we create an HTTP response with no content, using the provided Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response should include a Content-Type header */ + self::assertTrue($actual->hasHeader(name: 'Content-Type')); + + /** @And the Content-Type header should be set to application/pdf */ + $expected = 'application/pdf'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); + self::assertSame(['Content-Type' => $expected], $actual->getHeaders()); + } + + public function testResponseWithContentTypeHTML(): void + { + /** @Given the Content-Type header is set to text/html */ + $contentType = ContentType::textHtml(); + + /** @When we create an HTTP response with no content, using the provided Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response should include a Content-Type header */ + self::assertTrue($actual->hasHeader(name: 'Content-Type')); + + /** @And the Content-Type header should be set to text/html */ + $expected = 'text/html'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); + self::assertSame(['Content-Type' => $expected], $actual->getHeaders()); + } + + public function testResponseWithContentTypeJSON(): void + { + /** @Given the Content-Type header is set to application/json */ + $contentType = ContentType::applicationJson(); + + /** @When we create an HTTP response with no content, using the provided Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response should include a Content-Type header */ + self::assertTrue($actual->hasHeader(name: 'Content-Type')); + + /** @And the Content-Type header should be set to application/json */ + $expected = 'application/json'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); + self::assertSame(['Content-Type' => $expected], $actual->getHeaders()); + } + + public function testResponseWithContentTypePlainText(): void + { + /** @Given the Content-Type header is set to text/plain */ + $contentType = ContentType::textPlain(); + + /** @When we create an HTTP response with no content, using the provided Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response should include a Content-Type header */ + self::assertTrue($actual->hasHeader(name: 'Content-Type')); + + /** @And the Content-Type header should be set to text/plain */ + $expected = 'text/plain'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); + self::assertSame(['Content-Type' => $expected], $actual->getHeaders()); + } + + public function testResponseWithContentTypeOctetStream(): void + { + /** @Given the Content-Type header is set to application/octet-stream */ + $contentType = ContentType::applicationOctetStream(); + + /** @When we create an HTTP response with no content, using the provided Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response should include a Content-Type header */ + self::assertTrue($actual->hasHeader(name: 'Content-Type')); + + /** @And the Content-Type header should be set to application/octet-stream */ + $expected = 'application/octet-stream'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); + self::assertSame(['Content-Type' => $expected], $actual->getHeaders()); + } + + public function testResponseWithContentTypeFormUrlencoded(): void + { + /** @Given the Content-Type header is set to application/x-www-form-urlencoded */ + $contentType = ContentType::applicationFormUrlencoded(); + + /** @When we create an HTTP response with no content, using the provided Content-Type */ + $actual = Response::noContent($contentType); + + /** @Then the response should include a Content-Type header */ + self::assertTrue($actual->hasHeader(name: 'Content-Type')); + + /** @And the Content-Type header should be set to application/x-www-form-urlencoded */ + $expected = 'application/x-www-form-urlencoded'; + + self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); + self::assertSame(['Content-Type' => $expected], $actual->getHeaders()); + } +} diff --git a/tests/Response/ProtocolVersionTest.php b/tests/Response/ProtocolVersionTest.php new file mode 100644 index 0000000..23a0230 --- /dev/null +++ b/tests/Response/ProtocolVersionTest.php @@ -0,0 +1,26 @@ +getProtocolVersion()); + + /** @When the protocol version is updated to HTTP/3 */ + $actual = $response->withProtocolVersion(version: '3'); + + /** @Then the response should use the updated protocol version 3 */ + self::assertSame('3', $actual->getProtocolVersion()); + } +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php new file mode 100644 index 0000000..6f98c0b --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,390 @@ + PHP_INT_MAX, + 'name' => 'Drakengard Firestorm', + 'type' => 'Dragon', + 'weight' => 6000.00 + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::ok(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 200 */ + self::assertSame(Code::OK->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "OK" */ + self::assertSame(Code::OK->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseCreated(): void + { + /** @Given a body with data */ + $body = [ + 'id' => 1, + 'name' => 'New Resource', + 'type' => 'Item', + 'weight' => 100.00 + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::created(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 201 */ + self::assertSame(Code::CREATED->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Created" */ + self::assertSame(Code::CREATED->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseAccepted(): void + { + /** @Given a body with data */ + $body = [ + 'id' => 1, + 'status' => 'Processing' + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::accepted(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 202 */ + self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Accepted" */ + self::assertSame(Code::ACCEPTED->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseNoContent(): void + { + /** @Given I have no data for the body */ + /** @When we create the HTTP response without body */ + $actual = Response::noContent(); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should be empty */ + self::assertEmpty($actual->getBody()->__toString()); + self::assertEmpty($actual->getBody()->getContents()); + + /** @And the status code should be 204 */ + self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "No Content" */ + self::assertSame(Code::NO_CONTENT->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseBadRequest(): void + { + /** @Given a body with error details */ + $body = [ + 'error' => 'Invalid request', + 'message' => 'The request body is malformed.' + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::badRequest(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 400 */ + self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Bad Request" */ + self::assertSame(Code::BAD_REQUEST->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseNotFound(): void + { + /** @Given a body with error details */ + $body = [ + 'error' => 'Not found', + 'message' => 'The requested resource could not be found.' + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::notFound(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 404 */ + self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Not Found" */ + self::assertSame(Code::NOT_FOUND->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseConflict(): void + { + /** @Given a body with conflict details */ + $body = [ + 'error' => 'Conflict', + 'message' => 'There is a conflict with the current state of the resource.' + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::conflict(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 409 */ + self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Conflict" */ + self::assertSame(Code::CONFLICT->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseUnprocessableEntity(): void + { + /** @Given a body with validation errors */ + $body = [ + 'error' => 'Validation Failed', + 'message' => 'The input data did not pass validation.' + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::unprocessableEntity(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 422 */ + self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Unprocessable Entity" */ + self::assertSame(Code::UNPROCESSABLE_ENTITY->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + public function testResponseInternalServerError(): void + { + /** @Given a body with error details */ + $body = [ + 'code' => 10000, + 'message' => 'An unexpected error occurred on the server.' + ]; + + /** @When we create the HTTP response with this body */ + $actual = Response::internalServerError(body: $body); + + /** @Then the protocol version should be "1.1" */ + self::assertSame('1.1', $actual->getProtocolVersion()); + + /** @And the body of the response should match the JSON-encoded body */ + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); + self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); + + /** @And the status code should be 500 */ + self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); + self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + + /** @And the reason phrase should be "Internal Server Error" */ + self::assertSame(Code::INTERNAL_SERVER_ERROR->message(), $actual->getReasonPhrase()); + + /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + self::assertSame(['Content-Type' => 'application/json; charset=utf-8'], $actual->getHeaders()); + } + + #[DataProvider('bodyProviderData')] + public function testResponseBodySerialization(mixed $body, string $expected): void + { + /** @Given the body contains the provided data */ + /** @When we create an HTTP response with the given body */ + $actual = Response::ok(body: $body); + + /** @Then the body of the response should match the expected output */ + self::assertSame($expected, $actual->getBody()->__toString()); + self::assertSame($expected, $actual->getBody()->getContents()); + } + + public function testResponseWithBody(): void + { + /** @Given an HTTP response with without body */ + $response = Response::ok(body: null); + + /** @When the body of the response is initially empty */ + self::assertEmpty($response->getBody()->__toString()); + self::assertEmpty($response->getBody()->getContents()); + + /** @And a new body is set for the response */ + $body = 'This is a new body'; + $actual = $response->withBody(body: StreamFactory::fromBody(body: $body)->write()); + + /** @Then the response body should be updated to match the new content */ + self::assertSame($body, $actual->getBody()->__toString()); + self::assertSame($body, $actual->getBody()->getContents()); + } + + public function testExceptionWhenBadMethodCallOnWithStatus(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then a BadMethodCall exception should be thrown when calling withStatus */ + self::expectException(BadMethodCall::class); + self::expectExceptionMessage('Method cannot be used.'); + + /** @When attempting to call withStatus */ + $response->withStatus(code: Code::OK->value); + } + + public static function bodyProviderData(): array + { + return [ + 'UnitEnum' => [ + 'body' => Color::RED, + 'expected' => 'RED' + ], + 'BackedEnum' => [ + 'body' => Status::PAID, + 'expected' => '1' + ], + 'Null value' => [ + 'body' => null, + 'expected' => '' + ], + 'Empty string' => [ + 'body' => '', + 'expected' => '' + ], + 'Simple object' => [ + 'body' => new Dragon(name: 'Drakengard Firestorm', weight: 6000.0), + 'expected' => '{"name":"Drakengard Firestorm","weight":6000.0}' + ], + 'Non-empty string' => [ + 'body' => 'Hello, World!', + 'expected' => 'Hello, World!' + ], + 'Serializer object' => [ + 'body' => new Order( + id: 1, + products: new Products(elements: [ + new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD)), + 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"}}]}' + ], + 'Boolean true value' => [ + 'body' => true, + 'expected' => 'true' + ], + 'Boolean false value' => [ + 'body' => false, + 'expected' => 'false' + ], + 'Large integer value' => [ + 'body' => PHP_INT_MAX, + 'expected' => (string)PHP_INT_MAX + ], + 'DateTimeInterface value' => [ + 'body' => new DateTime('2024-12-16'), + 'expected' => '[]' + ] + ]; + } +}