Skip to content

Commit 2aefd86

Browse files
committed
refactor: stability and test adjustments
1 parent 5a67c19 commit 2aefd86

File tree

10 files changed

+134
-103
lines changed

10 files changed

+134
-103
lines changed

packages/http/src/Header.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ final class Header
88
{
99
public function __construct(
1010
public string $name,
11-
/** @var array<array-key, mixed> $values */
11+
/** @var array<array-key,mixed> $values */
1212
public array $values = [],
1313
) {}
1414

1515
public function add(mixed $value): void
1616
{
1717
$this->values[] = $value;
1818
}
19+
20+
public function first(): mixed
21+
{
22+
return array_first($this->values);
23+
}
1924
}

packages/http/src/RequestHeaders.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ public function offsetGet(mixed $offset): string
4343

4444
public function get(string $name): ?string
4545
{
46-
return $this->headers[strtolower($name)] ?? null;
46+
return array_find(
47+
array: $this->headers,
48+
callback: fn (mixed $_, string $header) => strcasecmp($header, $name) === 0,
49+
);
4750
}
4851

4952
public function has(string $name): bool
@@ -53,7 +56,7 @@ public function has(string $name): bool
5356

5457
public function getHeader(string $name): Header
5558
{
56-
return new Header(strtolower($name), array_filter([$this->get($name)]));
59+
return new Header(mb_strtolower($name), array_filter([$this->get($name)]));
5760
}
5861

5962
public function offsetSet(mixed $offset, mixed $value): void

packages/http/src/Responses/Invalid.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ public function __construct(
4545
$this->flash(Session::VALIDATION_ERRORS, $failingRules);
4646
$this->flash(Session::ORIGINAL_VALUES, $this->filterSensitiveFields($request, $targetClass));
4747
$this->addHeader('x-validation', value: Json\encode(
48-
Arr\map_iterable($failingRules, fn (array $failingRulesForField) => Arr\map_iterable(
48+
Arr\map_iterable($failingRules, fn (array $failingRulesForField, string $field) => Arr\map_iterable(
4949
array: $failingRulesForField,
50-
map: fn (FailingRule $rule) => $this->validator->getErrorMessage($rule),
50+
map: fn (FailingRule $rule) => $this->validator->getErrorMessage($rule, $field),
5151
)),
5252
));
5353
}

packages/router/src/Exceptions/HtmlExceptionRenderer.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use Tempest\Auth\Exceptions\AccessWasDenied;
66
use Tempest\Container\Container;
77
use Tempest\Core\AppConfig;
8+
use Tempest\Core\Priority;
9+
use Tempest\Discovery\SkipDiscovery;
810
use Tempest\Http\ContentType;
911
use Tempest\Http\GenericResponse;
1012
use Tempest\Http\HttpRequestFailed;
@@ -21,6 +23,11 @@
2123
use Whoops\Handler\PrettyPageHandler;
2224
use Whoops\Run;
2325

26+
/**
27+
* Renders exceptions for HTML content. The priority is lowered by one because
28+
* JSON-rendering should be the default for requests without `Accept` header.
29+
*/
30+
#[Priority(Priority::LOWEST + 1)]
2431
final readonly class HtmlExceptionRenderer implements ExceptionRenderer
2532
{
2633
public function __construct(
@@ -49,6 +56,7 @@ public function render(Throwable $throwable): Response
4956
}
5057

5158
return match (true) {
59+
$throwable instanceof ConvertsToResponse => $throwable->toResponse(),
5260
$throwable instanceof ValidationFailed => new Invalid($throwable->subject, $throwable->failingRules, $throwable->targetClass),
5361
$throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN),
5462
$throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable),

packages/router/src/Exceptions/HttpExceptionHandler.php

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
use Tempest\Core\ExceptionHandler;
77
use Tempest\Core\Exceptions\ExceptionProcessor;
88
use Tempest\Core\Kernel;
9-
use Tempest\Http\ContentType;
109
use Tempest\Http\GenericResponse;
1110
use Tempest\Http\Request;
1211
use Tempest\Http\Response;
1312
use Tempest\Http\Status;
1413
use Tempest\Router\ResponseSender;
1514
use Tempest\Router\RouteConfig;
15+
use Tempest\Support\Arr;
1616
use Throwable;
1717

1818
final readonly class HttpExceptionHandler implements ExceptionHandler
@@ -23,8 +23,6 @@ public function __construct(
2323
private Container $container,
2424
private ExceptionProcessor $exceptionProcessor,
2525
private RouteConfig $routeConfig,
26-
private JsonExceptionRenderer $jsonHandler,
27-
private HtmlExceptionRenderer $htmlHandler,
2826
) {}
2927

3028
public function handle(Throwable $throwable): void
@@ -41,10 +39,9 @@ public function handle(Throwable $throwable): void
4139

4240
public function renderResponse(Request $request, Throwable $throwable): Response
4341
{
44-
// Sort by priority ascending (HIGHEST = 0 checked first, LOWEST = 10000 checked last)
4542
ksort($this->routeConfig->exceptionRenderers);
4643

47-
foreach ($this->routeConfig->exceptionRenderers as $rendererClass) {
44+
foreach (Arr\flatten($this->routeConfig->exceptionRenderers) as $rendererClass) {
4845
/** @var ExceptionRenderer $renderer */
4946
$renderer = $this->container->get($rendererClass);
5047

@@ -53,15 +50,6 @@ public function renderResponse(Request $request, Throwable $throwable): Response
5350
}
5451
}
5552

56-
// Fall back to default renderers
57-
if ($this->htmlHandler->canRender($throwable, $request)) {
58-
return $this->htmlHandler->render($throwable);
59-
}
60-
61-
if ($this->jsonHandler->canRender($throwable, $request)) {
62-
return $this->jsonHandler->render($throwable);
63-
}
64-
6553
return new GenericResponse(status: Status::NOT_ACCEPTABLE);
6654
}
6755
}

packages/router/src/Exceptions/JsonExceptionRenderer.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Tempest\Auth\Exceptions\AccessWasDenied;
66
use Tempest\Core\AppConfig;
7+
use Tempest\Core\Priority;
78
use Tempest\Http\ContentType;
89
use Tempest\Http\HttpRequestFailed;
910
use Tempest\Http\Request;
@@ -18,6 +19,9 @@
1819
use Tempest\Validation\Validator;
1920
use Throwable;
2021

22+
use function Tempest\Support\Json\encode;
23+
24+
#[Priority(Priority::LOWEST)]
2125
final readonly class JsonExceptionRenderer implements ExceptionRenderer
2226
{
2327
public function __construct(
@@ -33,6 +37,7 @@ public function canRender(Throwable $throwable, Request $request): bool
3337
public function render(Throwable $throwable): Response
3438
{
3539
return match (true) {
40+
$throwable instanceof ConvertsToResponse => $throwable->toResponse(),
3641
$throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable),
3742
$throwable instanceof ValidationFailed => $this->renderValidationErrorResponse($throwable),
3843
$throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN),
@@ -51,7 +56,9 @@ private function renderValidationErrorResponse(ValidationFailed $exception): Res
5156
return new Json([
5257
'message' => Arr\first($errors)[0],
5358
'errors' => $errors,
54-
])->setStatus(Status::UNPROCESSABLE_CONTENT);
59+
])
60+
->setStatus(Status::UNPROCESSABLE_CONTENT)
61+
->addHeader('x-validation', value: encode($errors));
5562
}
5663

5764
private function renderErrorResponse(Status $status, ?Throwable $exception = null): Response

packages/router/src/RouteConfig.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public function addResponseProcessor(string $responseProcessor): void
5151

5252
public function addExceptionRenderer(string $exceptionRenderer, int $priority): void
5353
{
54-
$this->exceptionRenderers[$priority] = $exceptionRenderer;
54+
$this->exceptionRenderers[$priority] ??= [];
55+
$this->exceptionRenderers[$priority][] = $exceptionRenderer;
5556
}
5657
}

src/Tempest/Framework/Testing/Http/HttpRouterTester.php

Lines changed: 76 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Laminas\Diactoros\ServerRequestFactory;
88
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
99
use Tempest\Container\Container;
10+
use Tempest\Http\ContentType;
1011
use Tempest\Http\GenericRequest;
1112
use Tempest\Http\Mappers\RequestToPsrRequestMapper;
1213
use Tempest\Http\Method;
@@ -20,20 +21,30 @@
2021

2122
final class HttpRouterTester
2223
{
24+
private(set) ?ContentType $contentType = null;
25+
2326
public function __construct(
2427
private Container $container,
2528
) {}
2629

30+
/**
31+
* Specifies the "Accept" header for subsequent requests.
32+
*/
33+
public function as(ContentType $contentType): self
34+
{
35+
$this->contentType = $contentType;
36+
37+
return $this;
38+
}
39+
2740
public function get(string $uri, array $query = [], array $headers = []): TestResponseHelper
2841
{
29-
return $this->sendRequest(
30-
new GenericRequest(
31-
method: Method::GET,
32-
uri: Uri\merge_query($uri, ...$query),
33-
body: [],
34-
headers: $headers,
35-
),
36-
);
42+
return $this->sendRequest(new GenericRequest(
43+
method: Method::GET,
44+
uri: Uri\merge_query($uri, ...$query),
45+
body: [],
46+
headers: $this->createHeaders($headers),
47+
));
3748
}
3849

3950
public function head(string $uri, array $query = [], array $headers = []): TestResponseHelper
@@ -43,93 +54,79 @@ public function head(string $uri, array $query = [], array $headers = []): TestR
4354
method: Method::HEAD,
4455
uri: Uri\merge_query($uri, ...$query),
4556
body: [],
46-
headers: $headers,
57+
headers: $this->createHeaders($headers),
4758
),
4859
);
4960
}
5061

5162
public function post(string $uri, array $body = [], array $query = [], array $headers = []): TestResponseHelper
5263
{
53-
return $this->sendRequest(
54-
new GenericRequest(
55-
method: Method::POST,
56-
uri: Uri\merge_query($uri, ...$query),
57-
body: $body,
58-
headers: $headers,
59-
),
60-
);
64+
return $this->sendRequest(new GenericRequest(
65+
method: Method::POST,
66+
uri: Uri\merge_query($uri, ...$query),
67+
body: $body,
68+
headers: $this->createHeaders($headers),
69+
));
6170
}
6271

6372
public function put(string $uri, array $body = [], array $query = [], array $headers = []): TestResponseHelper
6473
{
65-
return $this->sendRequest(
66-
new GenericRequest(
67-
method: Method::PUT,
68-
uri: Uri\merge_query($uri, ...$query),
69-
body: $body,
70-
headers: $headers,
71-
),
72-
);
74+
return $this->sendRequest(new GenericRequest(
75+
method: Method::PUT,
76+
uri: Uri\merge_query($uri, ...$query),
77+
body: $body,
78+
headers: $this->createHeaders($headers),
79+
));
7380
}
7481

7582
public function delete(string $uri, array $body = [], array $query = [], array $headers = []): TestResponseHelper
7683
{
77-
return $this->sendRequest(
78-
new GenericRequest(
79-
method: Method::DELETE,
80-
uri: Uri\merge_query($uri, ...$query),
81-
body: $body,
82-
headers: $headers,
83-
),
84-
);
84+
return $this->sendRequest(new GenericRequest(
85+
method: Method::DELETE,
86+
uri: Uri\merge_query($uri, ...$query),
87+
body: $body,
88+
headers: $this->createHeaders($headers),
89+
));
8590
}
8691

8792
public function connect(string $uri, array $query = [], array $headers = []): TestResponseHelper
8893
{
89-
return $this->sendRequest(
90-
new GenericRequest(
91-
method: Method::CONNECT,
92-
uri: Uri\merge_query($uri, ...$query),
93-
body: [],
94-
headers: $headers,
95-
),
96-
);
94+
return $this->sendRequest(new GenericRequest(
95+
method: Method::CONNECT,
96+
uri: Uri\merge_query($uri, ...$query),
97+
body: [],
98+
headers: $this->createHeaders($headers),
99+
));
97100
}
98101

99102
public function options(string $uri, array $query = [], array $headers = []): TestResponseHelper
100103
{
101-
return $this->sendRequest(
102-
new GenericRequest(
103-
method: Method::OPTIONS,
104-
uri: Uri\merge_query($uri, ...$query),
105-
body: [],
106-
headers: $headers,
107-
),
108-
);
104+
return $this->sendRequest(new GenericRequest(
105+
method: Method::OPTIONS,
106+
uri: Uri\merge_query($uri, ...$query),
107+
body: [],
108+
headers: $this->createHeaders($headers),
109+
));
109110
}
110111

111112
public function trace(string $uri, array $query = [], array $headers = []): TestResponseHelper
112113
{
113-
return $this->sendRequest(
114-
new GenericRequest(
115-
method: Method::TRACE,
116-
uri: Uri\merge_query($uri, ...$query),
117-
body: [],
118-
headers: $headers,
119-
),
120-
);
114+
return $this->sendRequest(new GenericRequest(
115+
method: Method::TRACE,
116+
uri: Uri\merge_query($uri, ...$query),
117+
body: [],
118+
headers: $this->createHeaders($headers),
119+
));
121120
}
122121

123122
public function patch(string $uri, array $body = [], array $query = [], array $headers = []): TestResponseHelper
124123
{
125-
return $this->sendRequest(
126-
new GenericRequest(
127-
method: Method::PATCH,
128-
uri: Uri\merge_query($uri, ...$query),
129-
body: $body,
130-
headers: $headers,
131-
),
132-
);
124+
return $this->sendRequest(new GenericRequest(
125+
method: Method::PATCH,
126+
uri: Uri\merge_query($uri, ...$query),
127+
body: $body,
128+
headers: $this->createHeaders($headers),
129+
));
133130
}
134131

135132
public function sendRequest(Request $request): TestResponseHelper
@@ -178,4 +175,18 @@ public function makePsrRequest(
178175

179176
return ServerRequestFactory::fromGlobals()->withUploadedFiles($files);
180177
}
178+
179+
private function createHeaders(array $headers = []): array
180+
{
181+
$key = array_find_key(
182+
array: $headers,
183+
callback: fn (mixed $_, string $headerKey): bool => strcasecmp($headerKey, 'accept') === 0,
184+
);
185+
186+
if ($this->contentType) {
187+
$headers[$key ?? 'accept'] = $this->contentType->value;
188+
}
189+
190+
return $headers;
191+
}
181192
}

0 commit comments

Comments
 (0)