Skip to content

Commit be2fb43

Browse files
authored
feat(http): fix http header casing on retrieval (#1024)
1 parent bd22988 commit be2fb43

File tree

6 files changed

+145
-5
lines changed

6 files changed

+145
-5
lines changed

src/Tempest/Router/src/IsRequest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ trait IsRequest
2525
private(set) array $body = [];
2626

2727
#[SkipValidation]
28-
private(set) array $headers = [];
28+
private(set) RequestHeaders $headers;
2929

3030
#[SkipValidation]
3131
private(set) string $path;
@@ -52,7 +52,7 @@ public function __construct(
5252
$this->method = $method;
5353
$this->uri = $uri;
5454
$this->body = $body;
55-
$this->headers = $headers;
55+
$this->headers = RequestHeaders::normalizeFromArray($headers);
5656
$this->files = $files;
5757

5858
$this->path ??= $this->resolvePath();

src/Tempest/Router/src/Mappers/PsrRequestToGenericRequestMapper.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Tempest\Http\Method;
1010
use Tempest\Mapper\Mapper;
1111
use Tempest\Router\GenericRequest;
12+
use Tempest\Router\RequestHeaders;
1213
use Tempest\Router\Upload;
1314

1415
use function Tempest\map;
@@ -50,7 +51,7 @@ public function map(mixed $from, mixed $to): GenericRequest
5051
'method' => Method::from($from->getMethod()),
5152
'uri' => (string) $from->getUri(),
5253
'body' => $data,
53-
'headers' => $headersAsString,
54+
'headers' => RequestHeaders::normalizeFromArray($headersAsString),
5455
'path' => $from->getUri()->getPath(),
5556
'query' => $query,
5657
'files' => $uploads,

src/Tempest/Router/src/Mappers/RequestToPsrRequestMapper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public function map(mixed $from, mixed $to): PsrRequest
2323
uploadedFiles: $from->files,
2424
uri: $from->uri,
2525
method: $from->method->value,
26-
headers: $from->headers,
26+
headers: $from->headers->toArray(),
2727
cookieParams: $from->cookies,
2828
queryParams: $from->query,
2929
parsedBody: $from->body,

src/Tempest/Router/src/Request.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface Request
2121
get;
2222
}
2323

24-
public array $headers {
24+
public RequestHeaders $headers {
2525
get;
2626
}
2727

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router;
6+
7+
use ArrayAccess;
8+
use ArrayIterator;
9+
use IteratorAggregate;
10+
use LogicException;
11+
use Traversable;
12+
13+
final readonly class RequestHeaders implements ArrayAccess, IteratorAggregate
14+
{
15+
/**
16+
* @param array<string, string> $headers
17+
*/
18+
public static function normalizeFromArray(array $headers): self
19+
{
20+
$normalized = array_combine(
21+
array_map(strtolower(...), array_keys($headers)),
22+
array_values($headers),
23+
);
24+
return new self($normalized);
25+
}
26+
27+
/** @param array<string, string> $headers */
28+
private function __construct(
29+
private array $headers = [],
30+
) {
31+
}
32+
33+
public function offsetExists(mixed $offset): bool
34+
{
35+
$offset = strtolower($offset);
36+
37+
return isset($this->headers[$offset]);
38+
}
39+
40+
public function offsetGet(mixed $offset): string
41+
{
42+
return $this->get((string) $offset);
43+
}
44+
45+
public function get(string $name): string
46+
{
47+
return $this->headers[strtolower($name)];
48+
}
49+
50+
public function getHeader(string $name): Header
51+
{
52+
return new Header(strtolower($name), [$this->get($name)]);
53+
}
54+
55+
public function offsetSet(mixed $offset, mixed $value): void
56+
{
57+
throw new LogicException('Unable to alter request headers.');
58+
}
59+
60+
public function offsetUnset(mixed $offset): void
61+
{
62+
throw new LogicException('Unable to alter request headers.');
63+
}
64+
65+
public function toArray(): array
66+
{
67+
return $this->headers;
68+
}
69+
70+
public function getIterator(): Traversable
71+
{
72+
return new ArrayIterator($this->headers);
73+
}
74+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Tests;
6+
7+
use LogicException;
8+
use PHPUnit\Framework\TestCase;
9+
use Tempest\Http\Method;
10+
use Tempest\Router\GenericRequest;
11+
use Tempest\Router\Header;
12+
use Tempest\Router\RequestHeaders;
13+
14+
final class GenericRequestTest extends TestCase
15+
{
16+
public function test_normalizes_header_access(): void
17+
{
18+
$upperCaseValue = 'UpperCase';
19+
$lowerCaseValue = 'LowerCase';
20+
21+
$request = new GenericRequest(
22+
method: Method::GET,
23+
uri: '/',
24+
headers: [
25+
'UPPERCASE' => $upperCaseValue,
26+
'lowercase' => $lowerCaseValue,
27+
],
28+
);
29+
30+
$this->assertSame($upperCaseValue, $request->headers['uppercase']);
31+
$this->assertSame($upperCaseValue, $request->headers['UPPerCasE']);
32+
$this->assertSame($lowerCaseValue, $request->headers['lowercase']);
33+
34+
$this->assertSame($upperCaseValue, $request->headers->get('UpperCase'));
35+
$this->assertEquals(
36+
new Header('uppercase', [$upperCaseValue]),
37+
$request->headers->getHeader('UpperCase'),
38+
);
39+
}
40+
41+
public function test_throws_on_set(): void
42+
{
43+
$headers = new GenericRequest(
44+
method: Method::GET,
45+
uri: '/',
46+
)->headers;
47+
48+
$this->expectException(LogicException::class);
49+
$headers['x'] = 'yes';
50+
}
51+
52+
public function test_throws_on_unset(): void
53+
{
54+
$headers = new GenericRequest(
55+
method: Method::GET,
56+
uri: '/',
57+
headers: [
58+
'x' => 'yes',
59+
],
60+
)->headers;
61+
62+
$this->expectException(LogicException::class);
63+
unset($headers['x']);
64+
}
65+
}

0 commit comments

Comments
 (0)