Skip to content

Commit 150b342

Browse files
committed
feat: intl support and consistency changes
1 parent 3d585cf commit 150b342

File tree

20 files changed

+543
-235
lines changed

20 files changed

+543
-235
lines changed

docs/1-essentials/01-routing.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ final class PasswordlessAuthenticationController
299299
public function __invoke(Request $request): Response
300300
{
301301
if (! $this->uri->hasValidSignature($request)) {
302-
return new Invalid();
302+
throw new HttpRequestFailed(Status::UNPROCESSABLE_CONTENT);
303303
}
304304

305305
// …
@@ -704,7 +704,6 @@ Tempest provides several response classes for common use cases, all implementing
704704
- {b`Tempest\Http\Responses\Back`} — redirects to previous page, accepts a fallback.
705705
- {b`Tempest\Http\Responses\Download`} — downloads a file from the browser.
706706
- {b`Tempest\Http\Responses\File`} — shows a file in the browser.
707-
- {b`Tempest\Http\Responses\Invalid`} — a response with form validation errors, redirecting to the previous page.
708707
- {b`Tempest\Http\Responses\NotFound`} — the 404 response. Accepts an optional body.
709708
- {b`Tempest\Http\Responses\ServerError`} — a 500 server error response.
710709

packages/http/src/GenericResponse.php

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,6 @@ public function __construct(
2222
$this->body = $body;
2323
$this->view = $view;
2424

25-
foreach ($headers as $key => $values) {
26-
if (! is_array($values)) {
27-
$values = [$values];
28-
}
29-
30-
foreach ($values as $value) {
31-
$this->addHeader($key, $value);
32-
}
33-
}
25+
$this->addHeaders($headers);
3426
}
3527
}

packages/http/src/IsResponse.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ public function getHeader(string $name): ?Header
4141
);
4242
}
4343

44+
public function addHeaders(array $headers): self
45+
{
46+
foreach ($headers as $key => $values) {
47+
if (! is_array($values)) {
48+
$values = [$values];
49+
}
50+
51+
foreach ($values as $value) {
52+
$this->addHeader($key, $value);
53+
}
54+
}
55+
56+
return $this;
57+
}
58+
4459
public function addHeader(string $key, string $value): self
4560
{
4661
$this->headers[$key] ??= new Header($key);

packages/http/src/RequestHeaders.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@ public function offsetGet(mixed $offset): string
4141
return $this->get((string) $offset);
4242
}
4343

44-
public function get(string $name): ?string
44+
public function get(string $name, ?string $default = null): ?string
4545
{
46-
return array_find(
46+
$header = array_find(
4747
array: $this->headers,
4848
callback: fn (mixed $_, string $header) => strcasecmp($header, $name) === 0,
4949
);
50+
51+
return $header ?? $default;
5052
}
5153

5254
public function has(string $name): bool

packages/http/src/Responses/Back.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
use function Tempest\get;
1414

15+
/**
16+
* This response is not fit for stateless requests.
17+
*/
1518
final class Back implements Response
1619
{
1720
use IsResponse;

packages/http/src/Responses/Invalid.php

Lines changed: 0 additions & 76 deletions
This file was deleted.

packages/http/src/Responses/Json.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ final class Json implements Response
1313
{
1414
use IsResponse;
1515

16-
public function __construct(JsonSerializable|array|null $body = null)
16+
public function __construct(JsonSerializable|array|null $body = null, ?Status $status = null, array $headers = [])
1717
{
18-
$this->status = Status::OK;
18+
$this->status = $status ?? Status::OK;
1919
$this->body = $body;
20+
2021
$this->addHeader('Accept', 'application/json');
2122
$this->addHeader('Content-Type', 'application/json');
23+
$this->addHeaders($headers);
2224
}
2325
}

packages/http/src/Responses/Redirect.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@
88
use Tempest\Http\Response;
99
use Tempest\Http\Status;
1010

11+
/**
12+
* This response is not fit for stateless requests.
13+
*/
1114
final class Redirect implements Response
1215
{
1316
use IsResponse;
1417

1518
public function __construct(
1619
private(set) string $to,
20+
bool $permanent = false,
1721
) {
18-
$this->status = Status::FOUND;
22+
$this->status = $permanent
23+
? Status::MOVED_PERMANENTLY
24+
: Status::FOUND;
25+
1926
$this->addHeader('Location', $to);
2027
}
2128

packages/router/src/Exceptions/HtmlExceptionRenderer.php

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,41 @@
33
namespace Tempest\Router\Exceptions;
44

55
use Tempest\Auth\Exceptions\AccessWasDenied;
6-
use Tempest\Container\Container;
76
use Tempest\Core\AppConfig;
87
use Tempest\Core\Priority;
98
use Tempest\Http\ContentType;
109
use Tempest\Http\GenericResponse;
1110
use Tempest\Http\HttpRequestFailed;
1211
use Tempest\Http\Request;
1312
use Tempest\Http\Response;
14-
use Tempest\Http\Responses\Invalid;
13+
use Tempest\Http\SensitiveField;
1514
use Tempest\Http\Session\CsrfTokenDidNotMatch;
15+
use Tempest\Http\Session\Session;
1616
use Tempest\Http\Status;
17+
use Tempest\Intl\Translator;
18+
use Tempest\Reflection\ClassReflector;
19+
use Tempest\Support\Arr;
1720
use Tempest\Support\Filesystem;
21+
use Tempest\Support\Json;
1822
use Tempest\Validation\Exceptions\ValidationFailed;
23+
use Tempest\Validation\FailingRule;
24+
use Tempest\Validation\Validator;
1925
use Tempest\View\GenericView;
2026
use Throwable;
2127

2228
/**
2329
* Renders exceptions for HTML content. The priority is lowered by one because
2430
* JSON-rendering should be the default for requests without `Accept` header.
2531
*/
26-
#[Priority(Priority::LOWEST + 1)]
32+
#[Priority(Priority::LOW)]
2733
final readonly class HtmlExceptionRenderer implements ExceptionRenderer
2834
{
2935
public function __construct(
3036
private AppConfig $appConfig,
31-
private Container $container,
37+
private Translator $translator,
38+
private Request $request,
39+
private Validator $validator,
40+
private Session $session,
3241
) {}
3342

3443
public function canRender(Throwable $throwable, Request $request): bool
@@ -39,45 +48,47 @@ public function canRender(Throwable $throwable, Request $request): bool
3948
public function render(Throwable $throwable): Response
4049
{
4150
$response = match (true) {
42-
$throwable instanceof ConvertsToResponse => $throwable->toResponse(),
43-
$throwable instanceof ValidationFailed => new Invalid($throwable->subject, $throwable->failingRules, $throwable->targetClass),
44-
$throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN),
45-
$throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable),
51+
$throwable instanceof ValidationFailed => $this->renderValidationFailedResponse($throwable),
52+
$throwable instanceof AccessWasDenied => $this->renderErrorResponse(Status::FORBIDDEN, message: $throwable->accessDecision->message),
4653
$throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT),
47-
default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR, $throwable),
54+
$throwable instanceof HttpRequestFailed => $this->renderHttpRequestFailed($throwable),
55+
$throwable instanceof ConvertsToResponse => $throwable->convertToResponse(),
56+
default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR),
4857
};
4958

5059
if ($this->shouldRenderDevelopmentException($throwable)) {
5160
return new DevelopmentException(
5261
throwable: $throwable,
5362
response: $response,
54-
request: $this->container->get(Request::class),
63+
request: $this->request,
5564
);
5665
}
5766

5867
return $response;
5968
}
6069

61-
private function renderErrorResponse(Status $status, ?Throwable $exception = null): Response
70+
private function renderHttpRequestFailed(HttpRequestFailed $exception): Response
6271
{
63-
if ($exception instanceof HttpRequestFailed && $exception->cause?->body) {
72+
if ($exception->cause && is_string($exception->cause->body)) {
73+
return $this->renderErrorResponse($exception->status, message: $exception->cause->body);
74+
}
75+
76+
if ($exception->cause && $exception->cause->body) {
6477
return $exception->cause;
6578
}
6679

80+
return $this->renderErrorResponse($exception->status);
81+
}
82+
83+
private function renderErrorResponse(Status $status, ?string $message = null): Response
84+
{
6785
return new GenericResponse(
6886
status: $status,
6987
body: new GenericView(__DIR__ . '/production/error.view.php', [
7088
'css' => $this->getStyleSheet(),
7189
'status' => $status->value,
7290
'title' => $status->description(),
73-
'message' => $exception?->getMessage() ?: match ($status) {
74-
Status::INTERNAL_SERVER_ERROR => 'An unexpected server error occurred',
75-
Status::NOT_FOUND => 'This page could not be found on the server',
76-
Status::FORBIDDEN => 'You do not have permission to access this page',
77-
Status::UNAUTHORIZED => 'You must be authenticated in to access this page',
78-
Status::UNPROCESSABLE_CONTENT => 'The request could not be processed due to invalid data',
79-
default => null,
80-
},
91+
'message' => $message ?? $this->translator->translate("http_status_error.{$status->value}"),
8192
]),
8293
);
8394
}
@@ -103,4 +114,52 @@ private function shouldRenderDevelopmentException(Throwable $throwable): bool
103114

104115
return true;
105116
}
117+
118+
private function renderValidationFailedResponse(ValidationFailed $exception): Response
119+
{
120+
$status = Status::UNPROCESSABLE_CONTENT;
121+
$headers = [];
122+
123+
if ($referer = $this->request->headers->get('referer')) {
124+
$headers['Location'] = $referer;
125+
$status = Status::FOUND;
126+
}
127+
128+
$this->session->flash(Session::VALIDATION_ERRORS, $exception->failingRules);
129+
$this->session->flash(Session::ORIGINAL_VALUES, $this->filterSensitiveFields($this->request, $exception->targetClass));
130+
131+
$errors = Arr\map_iterable($exception->failingRules, fn (array $failingRulesForField, string $field) => Arr\map_iterable(
132+
array: $failingRulesForField,
133+
map: fn (FailingRule $rule) => $this->validator->getErrorMessage($rule, $field),
134+
));
135+
136+
$headers['x-validation'] = Json\encode($errors);
137+
138+
return new GenericResponse(
139+
status: $status,
140+
headers: $headers,
141+
);
142+
}
143+
144+
/**
145+
* @param class-string|null $targetClass
146+
*/
147+
private function filterSensitiveFields(Request $request, ?string $targetClass): array
148+
{
149+
$body = $request->body;
150+
151+
if ($targetClass === null) {
152+
return $body;
153+
}
154+
155+
$reflector = new ClassReflector($targetClass);
156+
157+
foreach ($reflector->getPublicProperties() as $property) {
158+
if ($property->hasAttribute(SensitiveField::class)) {
159+
unset($body[$property->getName()]);
160+
}
161+
}
162+
163+
return $body;
164+
}
106165
}

0 commit comments

Comments
 (0)