diff --git a/README.md b/README.md
index e830ff3..8412c2d 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,25 @@ Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson())
->withHeader(name: 'X-Trace-Id', value: 'abc-123');
```
+Every header that reaches a response, whether through a `Headerable`, `withHeader()` or `withAddedHeader()`, is
+checked against RFC 9110. A name that is not a token raises `HeaderNameIsInvalid`, and a value carrying a control
+character other than horizontal tab (CR, LF, and NUL included) raises `HeaderValueIsInvalid`. Both extend
+`InvalidArgumentException`, as PSR-7 requires, so a header can never smuggle a line break into the response.
+
+The connection-specific headers `Connection`, `Keep-Alive`, `Proxy-Connection`, `Transfer-Encoding` and `Upgrade`, and
+`TE` with any value other than `trailers`, raise `HeaderIsConnectionSpecific` on the same paths, with names compared
+case-insensitively and the `TE` value trimmed and compared case-insensitively. They describe the connection rather than
+the message, HTTP/2 (RFC 9113 section 8.2.2) and HTTP/3 (RFC 9114 section 4.2) treat a message carrying one as
+malformed, and the server that writes the response to the wire owns them.
+
+A response starts on protocol version `1.1`, the PSR-7 convention. `withProtocolVersion()` accepts `1.0`, `1.1`, `2`
+and `3`, stores `2.0` and `3.0` as `2` and `3`, and raises `ProtocolVersionIsUnsupported` for anything else, since an
+emitter writes the version into the status line. To answer in the protocol the request arrived on, mirror its version:
+
+```php
+$response = Response::ok(body: ['ok' => true])->withProtocolVersion($request->getProtocolVersion());
+```
+
`withStatus($code, $reasonPhrase)` honors the supplied reason phrase: when a non-empty string is passed,
`getReasonPhrase()` returns it instead of the enum-derived phrase.
diff --git a/src/Exceptions/HeaderIsConnectionSpecific.php b/src/Exceptions/HeaderIsConnectionSpecific.php
new file mode 100644
index 0000000..054ea16
--- /dev/null
+++ b/src/Exceptions/HeaderIsConnectionSpecific.php
@@ -0,0 +1,40 @@
+Connection, Keep-Alive, Proxy-Connection,
+ * Transfer-Encoding and Upgrade describe the connection that carries a
+ * message, not the message, and HTTP/2 (RFC 9113 section 8.2.2) and HTTP/3 (RFC 9114 section 4.2)
+ * treat a message carrying one as malformed. TE is admitted only with the value
+ * trailers.
+ */
+final class HeaderIsConnectionSpecific extends InvalidArgumentException implements HttpException
+{
+ private const string REASON_TEMPLATE = 'Header <%s> is connection-specific and must not be set on a response. '
+ . 'RFC 9113 section 8.2.2 and RFC 9114 section 4.2 forbid it, and TE admits only the value trailers.';
+
+ private function __construct(string $name)
+ {
+ $template = HeaderIsConnectionSpecific::REASON_TEMPLATE;
+
+ parent::__construct(message: sprintf($template, $name));
+ }
+
+ /**
+ * Creates a HeaderIsConnectionSpecific signaling that the given header must not be set on a response.
+ *
+ * @param string $name The offending header name.
+ * @return HeaderIsConnectionSpecific The composed exception describing the connection-specific header.
+ */
+ public static function for(string $name): HeaderIsConnectionSpecific
+ {
+ return new HeaderIsConnectionSpecific(name: $name);
+ }
+}
diff --git a/src/Exceptions/ProtocolVersionIsUnsupported.php b/src/Exceptions/ProtocolVersionIsUnsupported.php
new file mode 100644
index 0000000..9d813ee
--- /dev/null
+++ b/src/Exceptions/ProtocolVersionIsUnsupported.php
@@ -0,0 +1,39 @@
+1.0,
+ * 1.1, 2 and 3. The forms 2.0 and
+ * 3.0 are accepted and stored as 2 and 3. Anything else is
+ * rejected, because an emitter writes the version into the status line.
+ */
+final class ProtocolVersionIsUnsupported extends InvalidArgumentException implements HttpException
+{
+ private const string REASON_TEMPLATE = 'Protocol version <%s> is not supported. Supported versions are 1.0, '
+ . '1.1, 2 and 3.';
+
+ private function __construct(string $version)
+ {
+ $template = ProtocolVersionIsUnsupported::REASON_TEMPLATE;
+
+ parent::__construct(message: sprintf($template, $version));
+ }
+
+ /**
+ * Creates a ProtocolVersionIsUnsupported signaling that the given version is not a supported HTTP version.
+ *
+ * @param string $version The offending protocol version.
+ * @return ProtocolVersionIsUnsupported The composed exception describing the unsupported version.
+ */
+ public static function for(string $version): ProtocolVersionIsUnsupported
+ {
+ return new ProtocolVersionIsUnsupported(version: $version);
+ }
+}
diff --git a/src/Headers.php b/src/Headers.php
index f00462a..52f76db 100644
--- a/src/Headers.php
+++ b/src/Headers.php
@@ -7,6 +7,8 @@
use Psr\Http\Message\MessageInterface;
use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid;
use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid;
+use TinyBlocks\Http\Internal\HeaderName;
+use TinyBlocks\Http\Internal\HeaderValue;
/**
* Case-insensitive collection of HTTP headers represented as a name-to-value map.
@@ -73,17 +75,13 @@ public static function empty(): Headers
*/
public static function fromArray(array $entries): Headers
{
- foreach ($entries as $name => $value) {
- if (!preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) {
- throw HeaderNameIsInvalid::for(name: $name);
- }
+ $fields = [];
- if (preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) === 1) {
- throw HeaderValueIsInvalid::for(value: $value);
- }
+ foreach ($entries as $name => $value) {
+ $fields[HeaderName::from(value: $name)->toString()] = HeaderValue::from(value: $value)->toString();
}
- return new Headers(entries: $entries);
+ return new Headers(entries: $fields);
}
/**
diff --git a/src/Internal/HeaderName.php b/src/Internal/HeaderName.php
new file mode 100644
index 0000000..13dcaa6
--- /dev/null
+++ b/src/Internal/HeaderName.php
@@ -0,0 +1,30 @@
+value;
+ }
+}
diff --git a/src/Internal/HeaderValue.php b/src/Internal/HeaderValue.php
new file mode 100644
index 0000000..fda4666
--- /dev/null
+++ b/src/Internal/HeaderValue.php
@@ -0,0 +1,30 @@
+value;
+ }
+}
diff --git a/src/Internal/Server/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php
index f7efc8d..041a32a 100644
--- a/src/Internal/Server/Response/InternalResponse.php
+++ b/src/Internal/Server/Response/InternalResponse.php
@@ -28,7 +28,7 @@ public static function createWithBody(mixed $body, Code $code, Headerable ...$he
body: StreamFactory::fromBody(body: $body)->write(),
code: $code,
headers: ResponseHeaders::fromWithDefaultContentType(...$headers),
- protocolVersion: ProtocolVersion::default(),
+ protocolVersion: ProtocolVersion::HTTP_1_1,
customReasonPhrase: null
);
}
@@ -39,7 +39,7 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re
body: StreamFactory::fromEmptyBody()->write(),
code: $code,
headers: ResponseHeaders::fromWithoutDefaultContentType(...$headers),
- protocolVersion: ProtocolVersion::default(),
+ protocolVersion: ProtocolVersion::HTTP_1_1,
customReasonPhrase: null
);
}
@@ -136,12 +136,12 @@ public function withAddedHeader(string $name, mixed $value): MessageInterface
public function getProtocolVersion(): string
{
- return $this->protocolVersion->version;
+ return $this->protocolVersion->value;
}
public function withProtocolVersion(string $version): MessageInterface
{
- $protocolVersion = ProtocolVersion::from(version: $version);
+ $protocolVersion = ProtocolVersion::fromVersion(version: $version);
return new InternalResponse(
body: $this->body,
diff --git a/src/Internal/Server/Response/ProtocolVersion.php b/src/Internal/Server/Response/ProtocolVersion.php
index aa3d186..c732233 100644
--- a/src/Internal/Server/Response/ProtocolVersion.php
+++ b/src/Internal/Server/Response/ProtocolVersion.php
@@ -4,21 +4,23 @@
namespace TinyBlocks\Http\Internal\Server\Response;
-final readonly class ProtocolVersion
-{
- private const string DEFAULT_PROTOCOL_VERSION = '1.1';
+use TinyBlocks\Http\Exceptions\ProtocolVersionIsUnsupported;
- private function __construct(public string $version)
- {
- }
+enum ProtocolVersion: string
+{
+ case HTTP_1_0 = '1.0';
+ case HTTP_1_1 = '1.1';
+ case HTTP_2 = '2';
+ case HTTP_3 = '3';
- public static function from(string $version): ProtocolVersion
+ public static function fromVersion(string $version): ProtocolVersion
{
- return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version);
- }
+ $canonical = match ($version) {
+ '2.0' => self::HTTP_2->value,
+ '3.0' => self::HTTP_3->value,
+ default => $version
+ };
- public static function default(): ProtocolVersion
- {
- return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION);
+ return (ProtocolVersion::tryFrom($canonical) ?? throw ProtocolVersionIsUnsupported::for(version: $version));
}
}
diff --git a/src/Internal/Server/Response/ResponseField.php b/src/Internal/Server/Response/ResponseField.php
new file mode 100644
index 0000000..7d95b6d
--- /dev/null
+++ b/src/Internal/Server/Response/ResponseField.php
@@ -0,0 +1,57 @@
+toString();
+ $loweredName = strtolower($fieldName);
+ $values = array_map(
+ static fn(string $fieldValue): string => HeaderValue::from(value: $fieldValue)->toString(),
+ is_array($value) ? $value : [$value]
+ );
+
+ $isOtherThanTrailers = static fn(string $fieldValue): bool => strtolower(trim($fieldValue)) !== self::TRAILERS;
+
+ if (
+ in_array($loweredName, self::CONNECTION_SPECIFIC, true)
+ || ($loweredName === self::TE && array_any($values, $isOtherThanTrailers))
+ ) {
+ throw HeaderIsConnectionSpecific::for(name: $fieldName);
+ }
+
+ return new ResponseField(name: $fieldName, values: $values);
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ public function values(): array
+ {
+ return $this->values;
+ }
+}
diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php
index 162366c..59586e4 100644
--- a/src/Internal/Server/Response/ResponseHeaders.php
+++ b/src/Internal/Server/Response/ResponseHeaders.php
@@ -19,8 +19,9 @@ private function __construct(private array $headers)
private static function mergeInto(Headerable $header, array $merged): array
{
foreach ($header->toArray() as $name => $value) {
- $values = is_array($value) ? $value : [$value];
- $merged[$name] = isset($merged[$name]) ? array_merge($merged[$name], $values) : $values;
+ $field = ResponseField::from(name: $name, value: $value);
+ $key = $field->name();
+ $merged[$key] = isset($merged[$key]) ? array_merge($merged[$key], $field->values()) : $field->values();
}
return $merged;
@@ -84,11 +85,12 @@ public function hasHeader(string $name): bool
public function withAdded(string $name, string|array $value): ResponseHeaders
{
$headers = $this->headers;
- $existingKey = $this->findKey(name: $name);
- $appended = is_array($value) ? $value : [$value];
+ $field = ResponseField::from(name: $name, value: $value);
+ $appended = $field->values();
+ $existingKey = $this->findKey(name: $field->name());
if (is_null($existingKey)) {
- $headers[$name] = $appended;
+ $headers[$field->name()] = $appended;
return new ResponseHeaders(headers: $headers);
}
@@ -121,9 +123,9 @@ public function removeByName(string $name): ResponseHeaders
public function withReplaced(string $name, string|array $value): ResponseHeaders
{
$headers = $this->headers;
- $existingKey = $this->findKey(name: $name);
- $targetKey = ($existingKey ?? $name);
- $headers[$targetKey] = is_array($value) ? $value : [$value];
+ $field = ResponseField::from(name: $name, value: $value);
+ $targetKey = ($this->findKey(name: $field->name()) ?? $field->name());
+ $headers[$targetKey] = $field->values();
return new ResponseHeaders(headers: $headers);
}
diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php
index 0af0c5c..66fcf2a 100644
--- a/tests/Unit/HeadersTest.php
+++ b/tests/Unit/HeadersTest.php
@@ -437,7 +437,9 @@ public static function invalidHeaderNameProvider(): array
'Name with CR' => ["foo\r"],
'Name with CRLF' => ["foo\r\nbar"],
'Name with tab' => ["foo\tbar"],
- 'Name with null byte' => ["foo\x00bar"]
+ 'Name with null byte' => ["foo\x00bar"],
+ 'Name ending in LF' => ["foo\n"],
+ 'Name ending in CRLF' => ["foo\r\n"]
];
}
@@ -447,7 +449,8 @@ public static function invalidHeaderValueProvider(): array
'Value with LF' => ["foo\nbar"],
'Value with CR' => ["foo\rbar"],
'CRLF injection' => ["foo\r\nbar: injected"],
- 'Value with null byte' => ["foo\x00bar"]
+ 'Value with null byte' => ["foo\x00bar"],
+ 'Value ending in LF' => ["foo\n"]
];
}
}
diff --git a/tests/Unit/RawHeader.php b/tests/Unit/RawHeader.php
new file mode 100644
index 0000000..1ba5e6c
--- /dev/null
+++ b/tests/Unit/RawHeader.php
@@ -0,0 +1,19 @@
+name => $this->value];
+ }
+}
diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php
index f67b2a2..6253d30 100644
--- a/tests/Unit/Server/HeadersTest.php
+++ b/tests/Unit/Server/HeadersTest.php
@@ -4,10 +4,15 @@
namespace Test\TinyBlocks\Http\Unit\Server;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use Test\TinyBlocks\Http\Unit\RawHeader;
use TinyBlocks\Http\CacheControl;
use TinyBlocks\Http\Charset;
use TinyBlocks\Http\ContentType;
+use TinyBlocks\Http\Exceptions\HeaderIsConnectionSpecific;
+use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid;
+use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid;
use TinyBlocks\Http\ResponseCacheDirectives;
use TinyBlocks\Http\Server\Response;
use TinyBlocks\Http\UserAgent;
@@ -48,6 +53,18 @@ public function testNoContentWhenInvokedThenCarriesNoContentType(): void
self::assertSame([], $response->getHeaders());
}
+ public function testWithHeaderWhenTeCarriesOnlyTrailersThenKeepsIt(): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @When TE is set to trailers, in another case and padded, the only value HTTP/2 and HTTP/3 admit */
+ $actual = $response->withHeader('te', ' Trailers ');
+
+ /** @Then the value is kept as given */
+ self::assertSame([' Trailers '], $actual->getHeader('TE'));
+ }
+
public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void
{
/** @Given an HTTP response with no custom headers */
@@ -114,6 +131,44 @@ public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void
self::assertSame('application/json', $actual->getHeaderLine('Content-Type'));
}
+ #[DataProvider('invalidHeaderNameProvider')]
+ public function testWithHeaderWhenNameIsInvalidThenHeaderNameIsInvalid(string $name): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the name is rejected */
+ $this->expectException(HeaderNameIsInvalid::class);
+
+ /** @When a header is set under a name that is not an RFC 9110 token */
+ $response->withHeader($name, 'value');
+ }
+
+ public function testWithHeaderWhenValueCarriesHorizontalTabThenKeepsIt(): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @When a header is set to a value carrying a horizontal tab, which RFC 9110 allows inside a value */
+ $actual = $response->withHeader('X-Trace', "first\tsecond");
+
+ /** @Then the value is kept as given */
+ self::assertSame(["first\tsecond"], $actual->getHeader('X-Trace'));
+ }
+
+ #[DataProvider('invalidHeaderValueProvider')]
+ public function testWithHeaderWhenValueIsInvalidThenHeaderValueIsInvalid(string $value): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the value is rejected */
+ $this->expectException(HeaderValueIsInvalid::class);
+
+ /** @When a header is set to a value carrying a forbidden control character */
+ $response->withHeader('X-Trace', $value);
+ }
+
public function testOkWhenNoContentTypeGivenThenCarriesDefaultContentType(): void
{
/** @When a response with a body is created without a media type */
@@ -161,6 +216,19 @@ public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): v
self::assertSame('text/plain', $actual->getHeaderLine('Content-Type'));
}
+ #[DataProvider('invalidHeaderNameProvider')]
+ public function testWithAddedHeaderWhenNameIsInvalidThenHeaderNameIsInvalid(string $name): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the name is rejected */
+ $this->expectException(HeaderNameIsInvalid::class);
+
+ /** @When a value is added under a name that is not an RFC 9110 token */
+ $response->withAddedHeader($name, 'value');
+ }
+
public function testWithHeaderWhenChainedWithDistinctKeysThenBothArePresent(): void
{
/** @Given an HTTP response */
@@ -211,6 +279,19 @@ public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt():
self::assertSame('application/octet-stream', $actual->getHeaderLine('Content-Type'));
}
+ #[DataProvider('invalidHeaderValueProvider')]
+ public function testWithAddedHeaderWhenValueIsInvalidThenHeaderValueIsInvalid(string $value): void
+ {
+ /** @Given an HTTP response already carrying the header */
+ $response = Response::noContent()->withHeader('X-Trace', 'first');
+
+ /** @Then the value is rejected */
+ $this->expectException(HeaderValueIsInvalid::class);
+
+ /** @When a value carrying a forbidden control character is added to it */
+ $response->withAddedHeader('X-Trace', $value);
+ }
+
public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void
{
/** @Given an HTTP response with a custom header */
@@ -237,6 +318,19 @@ public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue()
self::assertSame(['X-Trace' => ['only-value']], $actual->getHeaders());
}
+ #[DataProvider('invalidHeaderNameProvider')]
+ public function testNoContentWhenHeaderableNameIsInvalidThenHeaderNameIsInvalid(string $name): void
+ {
+ /** @Given a Headerable emitting a name that is not an RFC 9110 token */
+ $header = new RawHeader(name: $name, value: 'value');
+
+ /** @Then the name is rejected */
+ $this->expectException(HeaderNameIsInvalid::class);
+
+ /** @When a response is created with that header */
+ Response::noContent($header);
+ }
+
public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void
{
/** @Given the Content-Type header set to application/x-www-form-urlencoded */
@@ -249,6 +343,31 @@ public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt
self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine('Content-Type'));
}
+ #[DataProvider('invalidHeaderValueProvider')]
+ public function testNoContentWhenHeaderableValueIsInvalidThenHeaderValueIsInvalid(string $value): void
+ {
+ /** @Given a Headerable emitting a value carrying a forbidden control character */
+ $header = new RawHeader(name: 'X-Trace', value: $value);
+
+ /** @Then the value is rejected */
+ $this->expectException(HeaderValueIsInvalid::class);
+
+ /** @When a response is created with that header */
+ Response::noContent($header);
+ }
+
+ public function testWithHeaderWhenListCarriesInvalidValueThenHeaderValueIsInvalid(): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the list is rejected */
+ $this->expectException(HeaderValueIsInvalid::class);
+
+ /** @When a header is set to a list whose second value ends in a line feed */
+ $response->withHeader('X-Trace', ['first', "second\n"]);
+ }
+
public function testNoContentWhenMultipleHeaderablesGivenThenCacheControlIsPresent(): void
{
/** @Given a Cache-Control header */
@@ -321,4 +440,97 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe
/** @And nothing sits beside it, because a bodiless response invents no media type of its own */
self::assertSame($cacheControl->toArray(), $actual->getHeaders());
}
+
+ #[DataProvider('connectionSpecificHeaderProvider')]
+ public function testWithHeaderWhenHeaderIsConnectionSpecificThenHeaderIsConnectionSpecific(
+ string $name,
+ string $value
+ ): void {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the header is rejected */
+ $this->expectException(HeaderIsConnectionSpecific::class);
+
+ /** @When a connection-specific header is set */
+ $response->withHeader($name, $value);
+ }
+
+ #[DataProvider('connectionSpecificHeaderProvider')]
+ public function testNoContentWhenHeaderableIsConnectionSpecificThenHeaderIsConnectionSpecific(
+ string $name,
+ string $value
+ ): void {
+ /** @Given a Headerable emitting a connection-specific header */
+ $header = new RawHeader(name: $name, value: $value);
+
+ /** @Then the header is rejected */
+ $this->expectException(HeaderIsConnectionSpecific::class);
+
+ /** @When a response is created with that header */
+ Response::noContent($header);
+ }
+
+ public function testWithHeaderWhenTeListMixesTrailersWithACodingThenHeaderIsConnectionSpecific(): void
+ {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the header is rejected, naming it and the rules that forbid it */
+ $this->expectException(HeaderIsConnectionSpecific::class);
+ $this->expectExceptionMessage('Header is connection-specific and must not be set on a response.');
+
+ /** @When TE is set to a list holding trailers and a transfer coding */
+ $response->withHeader('TE', ['trailers', 'gzip']);
+ }
+
+ #[DataProvider('connectionSpecificHeaderProvider')]
+ public function testWithAddedHeaderWhenHeaderIsConnectionSpecificThenHeaderIsConnectionSpecific(
+ string $name,
+ string $value
+ ): void {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the header is rejected */
+ $this->expectException(HeaderIsConnectionSpecific::class);
+
+ /** @When a value is added under a connection-specific header */
+ $response->withAddedHeader($name, $value);
+ }
+
+ public static function invalidHeaderNameProvider(): array
+ {
+ return [
+ 'Empty name' => [''],
+ 'Name with space' => ['X Trace'],
+ 'Name with colon' => ['X-Trace:'],
+ 'Name ending in LF' => ["X-Trace\n"],
+ 'Name ending in CRLF' => ["X-Trace\r\n"],
+ 'Name with null byte' => ["X-Trace\x00"]
+ ];
+ }
+
+ public static function invalidHeaderValueProvider(): array
+ {
+ return [
+ 'Value with CR' => ["first\rsecond"],
+ 'CRLF injection' => ["first\r\nX-Injected: yes"],
+ 'Value ending in LF' => ["first\n"],
+ 'Value with null byte' => ["first\x00"]
+ ];
+ }
+
+ public static function connectionSpecificHeaderProvider(): array
+ {
+ return [
+ 'Connection' => ['name' => 'Connection', 'value' => 'close'],
+ 'Keep-Alive' => ['name' => 'Keep-Alive', 'value' => 'timeout=5'],
+ 'Proxy-Connection in upper' => ['name' => 'PROXY-CONNECTION', 'value' => 'keep-alive'],
+ 'Transfer-Encoding' => ['name' => 'Transfer-Encoding', 'value' => 'chunked'],
+ 'Upgrade in lower case' => ['name' => 'upgrade', 'value' => 'h2c'],
+ 'TE with a coding' => ['name' => 'TE', 'value' => 'gzip'],
+ 'TE with trailers and more' => ['name' => 'te', 'value' => 'trailers, deflate']
+ ];
+ }
}
diff --git a/tests/Unit/Server/ProtocolVersionTest.php b/tests/Unit/Server/ProtocolVersionTest.php
index 5635075..47aa71b 100644
--- a/tests/Unit/Server/ProtocolVersionTest.php
+++ b/tests/Unit/Server/ProtocolVersionTest.php
@@ -4,7 +4,9 @@
namespace Test\TinyBlocks\Http\Unit\Server;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use TinyBlocks\Http\Exceptions\ProtocolVersionIsUnsupported;
use TinyBlocks\Http\Server\Response;
final class ProtocolVersionTest extends TestCase
@@ -23,4 +25,60 @@ public function testWithProtocolVersionWhenInvokedThenReturnsResponseWithUpdated
/** @Then the response uses the updated protocol version 3 */
self::assertSame('3', $actual->getProtocolVersion());
}
+
+ #[DataProvider('supportedVersionProvider')]
+ public function testWithProtocolVersionWhenVersionIsSupportedThenCarriesItsCanonicalForm(
+ string $version,
+ string $expected
+ ): void {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @When the protocol version is set to a version the response supports */
+ $actual = $response->withProtocolVersion($version);
+
+ /** @Then the response carries the canonical form of that version */
+ self::assertSame($expected, $actual->getProtocolVersion());
+ }
+
+ #[DataProvider('unsupportedVersionProvider')]
+ public function testWithProtocolVersionWhenVersionIsUnsupportedThenProtocolVersionIsUnsupported(
+ string $version
+ ): void {
+ /** @Given an HTTP response */
+ $response = Response::noContent();
+
+ /** @Then the version is rejected, naming the versions that are supported */
+ $this->expectException(ProtocolVersionIsUnsupported::class);
+ $this->expectExceptionMessage('is not supported. Supported versions are 1.0, 1.1, 2 and 3.');
+
+ /** @When the protocol version is set to a version outside 1.0, 1.1, 2 and 3 */
+ $response->withProtocolVersion($version);
+ }
+
+ public static function supportedVersionProvider(): array
+ {
+ return [
+ 'HTTP/1.0' => ['version' => '1.0', 'expected' => '1.0'],
+ 'HTTP/1.1' => ['version' => '1.1', 'expected' => '1.1'],
+ 'HTTP/2' => ['version' => '2', 'expected' => '2'],
+ 'HTTP/2 as 2.0' => ['version' => '2.0', 'expected' => '2'],
+ 'HTTP/3' => ['version' => '3', 'expected' => '3'],
+ 'HTTP/3 as 3.0' => ['version' => '3.0', 'expected' => '3']
+ ];
+ }
+
+ public static function unsupportedVersionProvider(): array
+ {
+ return [
+ 'Empty version' => ['version' => ''],
+ 'HTTP/0.9' => ['version' => '0.9'],
+ 'Unknown minor' => ['version' => '1.2'],
+ 'Unknown HTTP/2 minor' => ['version' => '2.1'],
+ 'Unknown major' => ['version' => '4'],
+ 'With the prefix' => ['version' => 'HTTP/1.1'],
+ 'With trailing space' => ['version' => '1.1 '],
+ 'Ending in LF' => ['version' => "1.1\n"]
+ ];
+ }
}