Skip to content

Commit 1345e4b

Browse files
committed
feat(http): implement named error bags
1 parent accab81 commit 1345e4b

File tree

15 files changed

+568
-22
lines changed

15 files changed

+568
-22
lines changed

packages/http/src/Mappers/RequestToObjectMapper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function map(mixed $from, mixed $to): array|object
6060
$failingRules = $this->validator->validateValuesForClass($to, $data);
6161

6262
if ($failingRules !== []) {
63-
throw $this->validator->createValidationFailureException($failingRules, $from);
63+
throw $this->validator->createValidationFailureException($failingRules, $to);
6464
}
6565

6666
return map($data)->with(ArrayToObjectMapper::class)->to($to);

packages/http/src/Responses/Invalid.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use Tempest\Http\Response;
1010
use Tempest\Http\Session\Session;
1111
use Tempest\Http\Status;
12-
use Tempest\Intl\Translator;
1312
use Tempest\Support\Json;
1413
use Tempest\Validation\Rule;
1514
use Tempest\Validation\Validator;
@@ -25,20 +24,25 @@ final class Invalid implements Response
2524
get => get(Validator::class);
2625
}
2726

27+
private ?string $errorBag;
28+
2829
public function __construct(
2930
Request $request,
3031
/** @var \Tempest\Validation\Rule[][] $failingRules */
3132
array $failingRules = [],
33+
?string $errorBag = null,
3234
) {
35+
$this->errorBag = $errorBag;
3336
if ($referer = $request->headers['referer'] ?? null) {
3437
$this->addHeader('Location', $referer);
3538
$this->status = Status::FOUND;
3639
} else {
3740
$this->status = Status::BAD_REQUEST;
3841
}
3942

40-
$this->flash(Session::VALIDATION_ERRORS, $failingRules);
41-
$this->flash(Session::ORIGINAL_VALUES, $request->body);
43+
$session = get(Session::class);
44+
$session->flashValidationErrors($failingRules, $this->errorBag);
45+
$session->flashOriginalValues($request->body, $this->errorBag);
4246
$this->addHeader(
4347
'x-validation',
4448
Json\encode(
@@ -47,5 +51,16 @@ public function __construct(
4751
)->toArray())->toArray(),
4852
),
4953
);
54+
55+
if ($this->errorBag !== null && $this->errorBag !== Session::DEFAULT_ERROR_BAG) {
56+
$this->addHeader('x-validation-bag', $this->errorBag);
57+
}
58+
}
59+
60+
public function withErrorBag(string $bagName): self
61+
{
62+
$this->errorBag = $bagName;
63+
64+
return $this;
5065
}
5166
}

packages/http/src/Session/Session.php

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ final class Session
1919

2020
public const string CSRF_TOKEN_KEY = '#csrf_token';
2121

22+
public const string DEFAULT_ERROR_BAG = 'default';
23+
2224
private array $expiredKeys = [];
2325

2426
private SessionManager $manager {
@@ -84,14 +86,28 @@ public function get(string $key, mixed $default = null): mixed
8486
}
8587

8688
/** @return \Tempest\Validation\Rule[] */
87-
public function getErrorsFor(string $name): array
89+
public function getErrorsFor(string $name, ?string $bagName = null): array
8890
{
89-
return $this->get(self::VALIDATION_ERRORS)[$name] ?? [];
91+
$bagName ??= self::DEFAULT_ERROR_BAG;
92+
$errors = $this->get(self::VALIDATION_ERRORS);
93+
94+
if ($errors !== null && ! $this->isBaggedStructure($errors)) {
95+
return $bagName === self::DEFAULT_ERROR_BAG ? ($errors[$name] ?? []) : [];
96+
}
97+
98+
return $errors[$bagName][$name] ?? [];
9099
}
91100

92-
public function getOriginalValueFor(string $name, mixed $default = ''): mixed
101+
public function getOriginalValueFor(string $name, mixed $default = '', ?string $bagName = null): mixed
93102
{
94-
return $this->get(self::ORIGINAL_VALUES)[$name] ?? $default;
103+
$bagName ??= self::DEFAULT_ERROR_BAG;
104+
$values = $this->get(self::ORIGINAL_VALUES);
105+
106+
if ($values !== null && ! $this->isBaggedStructure($values)) {
107+
return $bagName === self::DEFAULT_ERROR_BAG ? ($values[$name] ?? $default) : $default;
108+
}
109+
110+
return $values[$bagName][$name] ?? $default;
95111
}
96112

97113
public function getPreviousUrl(): string
@@ -142,4 +158,96 @@ public function cleanup(): void
142158
$this->manager->remove($this->id, $key);
143159
}
144160
}
161+
162+
public function flashValidationErrors(array $errors, ?string $bagName = null): void
163+
{
164+
$bagName ??= self::DEFAULT_ERROR_BAG;
165+
$currentErrors = $this->get(self::VALIDATION_ERRORS) ?? [];
166+
167+
if (! $this->isBaggedStructure($currentErrors)) {
168+
$currentErrors = empty($currentErrors) ? [] : [self::DEFAULT_ERROR_BAG => $currentErrors];
169+
}
170+
171+
$currentErrors[$bagName] = $errors;
172+
$this->flash(self::VALIDATION_ERRORS, $currentErrors);
173+
}
174+
175+
public function flashOriginalValues(array $values, ?string $bagName = null): void
176+
{
177+
$bagName ??= self::DEFAULT_ERROR_BAG;
178+
$currentValues = $this->get(self::ORIGINAL_VALUES) ?? [];
179+
180+
if (! $this->isBaggedStructure($currentValues)) {
181+
$currentValues = empty($currentValues) ? [] : [self::DEFAULT_ERROR_BAG => $currentValues];
182+
}
183+
184+
$currentValues[$bagName] = $values;
185+
$this->flash(self::ORIGINAL_VALUES, $currentValues);
186+
}
187+
188+
public function getAllErrors(?string $bagName = null): array
189+
{
190+
$bagName ??= self::DEFAULT_ERROR_BAG;
191+
$errors = $this->get(self::VALIDATION_ERRORS);
192+
193+
if ($errors !== null && ! $this->isBaggedStructure($errors)) {
194+
return $bagName === self::DEFAULT_ERROR_BAG ? $errors : [];
195+
}
196+
197+
return $errors[$bagName] ?? [];
198+
}
199+
200+
public function clearErrors(?string $bagName = null): void
201+
{
202+
$bagName ??= self::DEFAULT_ERROR_BAG;
203+
$errors = $this->get(self::VALIDATION_ERRORS) ?? [];
204+
205+
if ($this->isBaggedStructure($errors)) {
206+
unset($errors[$bagName]);
207+
if (empty($errors)) {
208+
$this->remove(self::VALIDATION_ERRORS);
209+
} else {
210+
$this->set(self::VALIDATION_ERRORS, $errors);
211+
}
212+
} elseif ($bagName === self::DEFAULT_ERROR_BAG) {
213+
$this->remove(self::VALIDATION_ERRORS);
214+
}
215+
}
216+
217+
private function isBaggedStructure(array $data): bool
218+
{
219+
if (empty($data)) {
220+
return false; // Empty arrays are considered non-bagged for backward compatibility
221+
}
222+
223+
// Check if this looks like a bagged structure:
224+
// - Bagged: ['default' => ['field' => [...]], 'other' => ['field' => [...]]]
225+
// - Old: ['field' => [...], 'field2' => [...]]
226+
227+
// A bagged structure should have at least one known bag name as key
228+
// or all values should be arrays of arrays (not arrays of objects)
229+
foreach ($data as $key => $value) {
230+
if ($key === self::DEFAULT_ERROR_BAG) {
231+
return true;
232+
}
233+
234+
// If value is not an array, it's definitely not bagged
235+
if (! is_array($value)) {
236+
return false;
237+
}
238+
239+
// Check if the value contains objects (old structure for validation errors)
240+
// Old structure: ['field' => [Rule, Rule]]
241+
// New structure: ['bag' => ['field' => [Rule, Rule]]]
242+
foreach ($value as $item) {
243+
if (is_object($item)) {
244+
// Contains objects directly, so it's the old structure
245+
return false;
246+
}
247+
}
248+
}
249+
250+
// If all values are arrays of arrays (no objects), it's likely bagged
251+
return true;
252+
}
145253
}

packages/router/src/HandleRouteExceptionMiddleware.php

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Tempest\Router;
44

5+
use ReflectionClass;
56
use Tempest\Core\Priority;
67
use Tempest\Http\HttpRequestFailed;
78
use Tempest\Http\Request;
@@ -10,7 +11,9 @@
1011
use Tempest\Http\Responses\NotFound;
1112
use Tempest\Router\Exceptions\ConvertsToResponse;
1213
use Tempest\Router\Exceptions\RouteBindingFailed;
14+
use Tempest\Validation\ErrorBag;
1315
use Tempest\Validation\Exceptions\ValidationFailed;
16+
use Throwable;
1417

1518
#[Priority(Priority::FRAMEWORK - 10)]
1619
final readonly class HandleRouteExceptionMiddleware implements HttpMiddleware
@@ -47,7 +50,38 @@ private function forward(Request $request, HttpMiddlewareCallable $next): Respon
4750
} catch (RouteBindingFailed) {
4851
return new NotFound();
4952
} catch (ValidationFailed $validationException) {
50-
return new Invalid($validationException->subject, $validationException->failingRules);
53+
$errorBag = $this->resolveErrorBag($validationException->subject, $request);
54+
55+
return new Invalid(
56+
$request,
57+
$validationException->failingRules,
58+
$errorBag,
59+
);
60+
}
61+
}
62+
63+
private function resolveErrorBag(mixed $subject, Request $request): ?string
64+
{
65+
if (isset($request->body['__error_bag'])) {
66+
return $request->body['__error_bag'];
67+
}
68+
69+
if (! is_object($subject) && ! is_string($subject)) {
70+
return null;
5171
}
72+
73+
try {
74+
$reflectionClass = new ReflectionClass($subject);
75+
$attributes = $reflectionClass->getAttributes(ErrorBag::class);
76+
77+
if (! empty($attributes)) {
78+
$errorBag = $attributes[0]->newInstance();
79+
return $errorBag->name;
80+
}
81+
} catch (Throwable) {
82+
// If reflection fails, just return null
83+
}
84+
85+
return null;
5286
}
5387
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
10+
final readonly class ErrorBag
11+
{
12+
public function __construct(
13+
public string $name,
14+
) {}
15+
}

packages/view/src/Components/x-form.view.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
* @var string|null $action
44
* @var string|Method|null $method
55
* @var string|null $enctype
6+
* @var string|null $bag
67
*/
78

89
use Tempest\Http\Method;
910

1011
$action ??= null;
1112
$method ??= Method::POST;
13+
$bag ??= null;
1214

1315
if ($method instanceof Method) {
1416
$method = $method->value;
@@ -17,6 +19,10 @@
1719

1820
<form :action="$action" :method="$method" :enctype="$enctype">
1921
<x-csrf-token />
22+
23+
<?php if ($bag !== null): ?>
24+
<input type="hidden" name="__error_bag" value="{{ $bag }}" />
25+
<?php endif; ?>
2026

2127
<x-slot />
2228
</form>

packages/view/src/Components/x-input.view.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* @var string|null $id
66
* @var string|null $type
77
* @var string|null $default
8+
* @var string|null $bag
89
*/
910

1011
use Tempest\Http\Session\Session;
@@ -23,9 +24,10 @@
2324
$id ??= $name;
2425
$type ??= 'text';
2526
$default ??= null;
27+
$bag ??= null;
2628

27-
$errors = $session->getErrorsFor($name);
28-
$original = $session->getOriginalValueFor($name, $default);
29+
$errors = $session->getErrorsFor($name, $bag);
30+
$original = $session->getOriginalValueFor($name, $default, $bag);
2931
?>
3032

3133
<div>

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

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
use Tempest\View\View;
2323
use Tempest\View\ViewRenderer;
2424

25-
use function Tempest\get;
2625
use function Tempest\Support\arr;
2726

2827
final class TestResponseHelper
@@ -211,6 +210,10 @@ public function assertHasValidationError(string $key, ?Closure $callback = null)
211210
$session = $this->container->get(Session::class);
212211
$validationErrors = $session->get(Session::VALIDATION_ERRORS) ?? [];
213212

213+
if (! empty($validationErrors) && isset($validationErrors[Session::DEFAULT_ERROR_BAG])) {
214+
$validationErrors = $validationErrors[Session::DEFAULT_ERROR_BAG];
215+
}
216+
214217
Assert::assertArrayHasKey(
215218
key: $key,
216219
array: $validationErrors,
@@ -233,6 +236,20 @@ public function assertHasNoValidationsErrors(): self
233236
$session = $this->container->get(Session::class);
234237
$validationErrors = $session->get(Session::VALIDATION_ERRORS) ?? [];
235238

239+
if (! empty($validationErrors)) {
240+
$firstKey = array_key_first($validationErrors);
241+
if (is_string($firstKey) && is_array($validationErrors[$firstKey])) {
242+
$allErrors = [];
243+
foreach ($validationErrors as $bag => $errors) {
244+
foreach ($errors as $field => $rules) {
245+
$allErrors[$bag . '.' . $field] = $rules;
246+
}
247+
}
248+
249+
$validationErrors = $allErrors;
250+
}
251+
}
252+
236253
Assert::assertEmpty(
237254
actual: $validationErrors,
238255
message: arr($validationErrors)
@@ -494,14 +511,25 @@ public function assertHasJsonValidationErrors(array $expectedErrors): self
494511

495512
$session = $this->container->get(Session::class);
496513
$validator = $this->container->get(Validator::class);
497-
$validationRules = arr($session->get(Session::VALIDATION_ERRORS))->dot();
514+
515+
$errorBagName = $this->response->getHeader('x-validation-bag') ?? Session::DEFAULT_ERROR_BAG;
516+
$validationRules = arr($session->getAllErrors($errorBagName))->dot();
498517

499518
arr($expectedErrors)
500519
->dot()
501-
->each(fn ($expectedErrorValue, $expectedErrorKey) => Assert::assertEquals(
502-
expected: $expectedErrorValue,
503-
actual: $validator->getErrorMessage($validationRules->get($expectedErrorKey)),
504-
));
520+
->each(function ($expectedErrorValue, $expectedErrorKey) use ($validator, $validationRules): void {
521+
$rules = $validationRules->get($expectedErrorKey);
522+
if ($rules && is_array($rules)) {
523+
$rule = $rules[0] ?? null;
524+
} else {
525+
$rule = $rules;
526+
}
527+
528+
Assert::assertEquals(
529+
expected: $expectedErrorValue,
530+
actual: $rule ? $validator->getErrorMessage($rule) : null,
531+
);
532+
});
505533

506534
return $this;
507535
}

0 commit comments

Comments
 (0)