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' => '[]'
+ ]
+ ];
+ }
+}