Skip to content

Commit e70ef1f

Browse files
committed
feat: add back form session error and original value management
1 parent 89ec565 commit e70ef1f

File tree

12 files changed

+378
-65
lines changed

12 files changed

+378
-65
lines changed

docs/1-essentials/01-routing.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,12 @@ final readonly class AircraftController
449449

450450
When users submit forms—like updating profile settings, or posting comments—the data needs validation before processing. Tempest automatically validates request objects using type hints and validation attributes, then provides errors back to users when something is wrong.
451451

452-
On validation failure, Tempest either redirects back to the form (for web pages) or returns a 400 response (for stateless requests). Validation errors are available in two places:
452+
On validation failure, Tempest either redirects back to the form (for web pages) or returns a 422 response (for stateless requests). Validation errors are available in two places:
453453

454454
- As a JSON encoded string in the `{txt}X-Validation` header
455-
- Within the session stored in `Session::VALIDATION_ERRORS`
455+
- Through the `b{Tempest\Http\Session\FormSession}` class
456456

457-
The JSON-encoded header is available for APIs built with Tempest. The session errors are available for web pages. For web pages, Tempest provides built-in view components to display errors when they occur.
457+
For web pages, Tempest also provides built-in view components to display errors when they occur.
458458

459459
```html
460460
<x-form :action="uri(StorePostController::class)">
@@ -464,7 +464,7 @@ The JSON-encoded header is available for APIs built with Tempest. The session er
464464
</x-form>
465465
```
466466

467-
`{html}<x-form>` is a view component that automatically includes the CSRF token and defaults to sending `POST` requests. `{html}<x-input>` is a view component that renders a label, input field, and validation errors all at once.
467+
`{html}<x-form>` is a view component that defaults to sending `POST` requests. `{html}<x-input>` is a view component that renders a label, input field, and validation errors all at once.
468468

469469
:::info
470470
These built-in view components can be customized. Run `./tempest install view-components` and select the components to pull into the project. [Read more about installing view components here](../1-essentials/02-views.md#built-in-components).
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Session;
6+
7+
use Tempest\Container\Singleton;
8+
use Tempest\Validation\FailingRule;
9+
10+
/**
11+
* Manages form validation errors and original input values in the session.
12+
*/
13+
#[Singleton]
14+
final readonly class FormSession
15+
{
16+
private const string VALIDATION_ERRORS_KEY = '#validation_errors';
17+
private const string ORIGINAL_VALUES_KEY = '#original_values';
18+
19+
public function __construct(
20+
private Session $session,
21+
) {}
22+
23+
/**
24+
* Stores validation errors for the next request.
25+
*
26+
* @param array<string,FailingRule[]> $errors
27+
*/
28+
public function setErrors(array $errors): void
29+
{
30+
$this->session->flash(self::VALIDATION_ERRORS_KEY, $errors);
31+
}
32+
33+
/**
34+
* Gets all validation errors.
35+
*
36+
* @return array<string,FailingRule[]>
37+
*/
38+
public function getErrors(): array
39+
{
40+
return $this->session->get(self::VALIDATION_ERRORS_KEY, []);
41+
}
42+
43+
/**
44+
* Gets validation errors for a specific field.
45+
*
46+
* @return FailingRule[]
47+
*/
48+
public function getErrorsFor(string $field): array
49+
{
50+
return $this->getErrors()[$field] ?? [];
51+
}
52+
53+
/**
54+
* Checks if there are any validation errors.
55+
*/
56+
public function hasErrors(): bool
57+
{
58+
return $this->getErrors() !== [];
59+
}
60+
61+
/**
62+
* Checks if a specific field has validation errors.
63+
*/
64+
public function hasError(string $field): bool
65+
{
66+
return $this->getErrorsFor($field) !== [];
67+
}
68+
69+
/**
70+
* Stores each field's original form values for the next request.
71+
*
72+
* @param array<string,mixed> $values
73+
*/
74+
public function setOriginalValues(array $values): void
75+
{
76+
$this->session->flash(self::ORIGINAL_VALUES_KEY, $values);
77+
}
78+
79+
/**
80+
* Gets all original form values. The keys are the form fields.
81+
*
82+
* @return array<string,mixed>
83+
*/
84+
public function values(): array
85+
{
86+
return $this->session->get(self::ORIGINAL_VALUES_KEY, []);
87+
}
88+
89+
/**
90+
* Gets the original value for a specific field.
91+
*/
92+
public function getOriginalValueFor(string $field, mixed $default = null): mixed
93+
{
94+
return $this->values()[$field] ?? $default;
95+
}
96+
97+
/**
98+
* Clears all validation errors and original values.
99+
*/
100+
public function clear(): void
101+
{
102+
$this->session->remove(self::VALIDATION_ERRORS_KEY);
103+
$this->session->remove(self::ORIGINAL_VALUES_KEY);
104+
}
105+
}

packages/http/src/Session/ManageSessionLifecycleMiddleware.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
/**
1212
* This middleware is responsible for creating the session and saving it on response.
1313
*/
14-
#[Priority(Priority::FRAMEWORK)]
14+
#[Priority(Priority::FRAMEWORK - 20)]
1515
final readonly class ManageSessionLifecycleMiddleware implements HttpMiddleware
1616
{
1717
public function __construct(

packages/http/src/Session/Session.php

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,6 @@
1111

1212
final class Session
1313
{
14-
/**
15-
* The session key that holds validation errors.
16-
*/
17-
public const string VALIDATION_ERRORS = '#validation_errors';
18-
19-
/**
20-
* The session key that holds original input values.
21-
*/
22-
public const string ORIGINAL_VALUES = '#original_values';
23-
2414
/**
2515
* Stores the keys for session values that have expired.
2616
*/
@@ -131,24 +121,6 @@ public function clear(): void
131121
$this->data = [];
132122
}
133123

134-
/**
135-
* Gets the failing rules for the specified field.
136-
*
137-
* @return \Tempest\Validation\FailingRule[]
138-
*/
139-
public function getErrorsFor(string $field): array
140-
{
141-
return $this->get(self::VALIDATION_ERRORS)[$field] ?? [];
142-
}
143-
144-
/**
145-
* Gets the original input value for the specified field.
146-
*/
147-
public function getOriginalValueFor(string $field, mixed $default = ''): mixed
148-
{
149-
return $this->get(self::ORIGINAL_VALUES)[$field] ?? $default;
150-
}
151-
152124
public function __serialize(): array
153125
{
154126
return [

packages/router/src/Exceptions/HtmlExceptionRenderer.php

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

55
use Tempest\Auth\Exceptions\AccessWasDenied;
6+
use Tempest\Container\Container;
67
use Tempest\Core\AppConfig;
78
use Tempest\Core\Priority;
89
use Tempest\Http\ContentType;
@@ -11,6 +12,7 @@
1112
use Tempest\Http\Request;
1213
use Tempest\Http\Response;
1314
use Tempest\Http\SensitiveField;
15+
use Tempest\Http\Session\FormSession;
1416
use Tempest\Http\Session\Session;
1517
use Tempest\Http\Status;
1618
use Tempest\Intl\Translator;
@@ -32,7 +34,7 @@ public function __construct(
3234
private Translator $translator,
3335
private Request $request,
3436
private Validator $validator,
35-
private Session $session,
37+
private Container $container,
3638
) {}
3739

3840
public function canRender(Throwable $throwable, Request $request): bool
@@ -123,8 +125,10 @@ private function renderValidationFailedResponse(ValidationFailed $exception): Re
123125
$status = Status::FOUND;
124126
}
125127

126-
$this->session->flash(Session::VALIDATION_ERRORS, $exception->failingRules);
127-
$this->session->flash(Session::ORIGINAL_VALUES, $this->filterSensitiveFields($this->request, $exception->targetClass));
128+
if ($this->container->has(Session::class)) {
129+
$this->container->get(FormSession::class)->setErrors($exception->failingRules);
130+
$this->container->get(FormSession::class)->setOriginalValues($this->filterSensitiveFields($this->request, $exception->targetClass));
131+
}
128132

129133
$errors = Arr\map_iterable($exception->failingRules, fn (array $failingRulesForField, string $field) => Arr\map_iterable(
130134
array: $failingRulesForField,

packages/router/src/GenericRouter.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@
66

77
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
88
use Tempest\Container\Container;
9-
use Tempest\Core\AppConfig;
109
use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper;
1110
use Tempest\Http\Request;
1211
use Tempest\Http\Response;
1312
use Tempest\Http\Responses\Ok;
1413
use Tempest\Router\Exceptions\ControllerActionHadNoReturn;
1514
use Tempest\Router\Exceptions\MatchedRouteCouldNotBeResolved;
16-
use Tempest\Router\Routing\Matching\RouteMatcher;
1715
use Tempest\View\View;
1816

1917
use function Tempest\Mapper\map;
@@ -22,8 +20,6 @@
2220
{
2321
public function __construct(
2422
private Container $container,
25-
private RouteMatcher $routeMatcher,
26-
private AppConfig $appConfig,
2723
private RouteConfig $routeConfig,
2824
) {}
2925

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
* @var string|null $default
88
*/
99

10-
use Tempest\Http\Session\Session;
10+
use Tempest\Http\Session\FormSession;
1111
use Tempest\Validation\Validator;
1212

1313
use function Tempest\get;
1414
use function Tempest\Support\str;
1515

16-
/** @var Session $session */
17-
$session = get(Session::class);
16+
/** @var FormSession $formSession */
17+
$formSession = get(FormSession::class);
1818

1919
/** @var Validator $validator */
2020
$validator = get(Validator::class);
@@ -24,8 +24,8 @@
2424
$type ??= 'text';
2525
$default ??= null;
2626

27-
$errors = $session->getErrorsFor($name);
28-
$original = $session->getOriginalValueFor($name, $default);
27+
$errors = $formSession->getErrorsFor($name);
28+
$original = $formSession->getOriginalValueFor($name, $default);
2929
?>
3030

3131
<div>

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

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Tempest\Http\Cookie\Cookie;
1515
use Tempest\Http\Request;
1616
use Tempest\Http\Response;
17+
use Tempest\Http\Session\FormSession;
1718
use Tempest\Http\Session\Session;
1819
use Tempest\Http\Status;
1920
use Tempest\Support\Arr;
@@ -203,6 +204,55 @@ public function assertDoesNotHaveCookie(string $key, null|string|Closure $value
203204
return $this;
204205
}
205206

207+
public function assertHasForm(Closure $closure): self
208+
{
209+
$this->assertHasContainer();
210+
211+
$formSession = $this->container->get(FormSession::class);
212+
213+
if (false === $closure($formSession)) {
214+
Assert::fail('Failed validating form session.');
215+
}
216+
217+
return $this;
218+
}
219+
220+
/**
221+
* Asserts that the original form values in the session match the given values.
222+
*/
223+
public function assertHasFormOriginalValues(array $values): self
224+
{
225+
$this->assertHasContainer();
226+
227+
$formSession = $this->container->get(FormSession::class);
228+
$originalValues = $formSession->values();
229+
230+
foreach ($values as $key => $expectedValue) {
231+
Assert::assertArrayHasKey(
232+
key: $key,
233+
array: $originalValues,
234+
message: sprintf(
235+
'No original form value was set for [%s], available original form values: %s',
236+
$key,
237+
implode(', ', array_keys($originalValues)),
238+
),
239+
);
240+
241+
Assert::assertEquals(
242+
expected: $expectedValue,
243+
actual: $originalValues[$key],
244+
message: sprintf(
245+
'Original form value for [%s] does not match expected value. Expected: %s, Actual: %s',
246+
$key,
247+
var_export($expectedValue, return: true),
248+
var_export($originalValues[$key], return: true),
249+
),
250+
);
251+
}
252+
253+
return $this;
254+
}
255+
206256
public function assertHasSession(string $key, ?Closure $callback = null): self
207257
{
208258
$this->assertHasContainer();
@@ -253,8 +303,8 @@ public function assertHasValidationError(string $key, ?Closure $callback = null)
253303

254304
public function assertHasNoValidationsErrors(): self
255305
{
256-
$session = $this->container->get(Session::class);
257-
$validationErrors = $session->get(Session::VALIDATION_ERRORS) ?? [];
306+
$formSession = $this->container->get(FormSession::class);
307+
$validationErrors = $formSession->getErrors();
258308

259309
Assert::assertEmpty(
260310
actual: $validationErrors,

0 commit comments

Comments
 (0)