diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 16fa7bc1b0f5..9188dcbff22f 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -801,10 +801,10 @@ private function resolveCallableParams(ReflectionFunctionAbstract $reflection, a /** * Instantiates, authorizes, and validates a FormRequest class. * - * If authorization or validation fails, the FormRequest returns a - * ResponseInterface. The framework wraps it in a FormRequestException - * (which implements ResponsableInterface) so the response is sent - * without reaching the controller method. + * If the FormRequest returns a ResponseInterface, the framework wraps it + * in a FormRequestException (which implements ResponsableInterface) so the + * response is sent without reaching the controller method. When it returns + * null, the FormRequest is injected. * * @param class-string $className */ diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 28ad232c302d..c1f64afd5914 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -144,7 +144,10 @@ protected function prepareForValidation(array $data): array } /** - * Called when validation fails. Override to customize the failure response. + * Called when validation fails. + * + * Override to customize the failure response, or return null to let the + * controller handle the failed validation with the resolved FormRequest. * * The default implementation redirects back with input and flashes validation * errors via the standard ``_ci_validation_errors`` channel (the same channel @@ -155,7 +158,7 @@ protected function prepareForValidation(array $data): array * @param array $errors * @param array $preparedData */ - protected function failedValidation(array $errors, array $preparedData): ResponseInterface + protected function failedValidation(array $errors, array $preparedData): ?ResponseInterface { if ($this->shouldReturnJsonResponse()) { return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); @@ -256,7 +259,8 @@ protected function validationData(): array * Runs authorization and validation. Called by the framework before * injecting the FormRequest into the controller method. * - * Returns null on success, or a ResponseInterface to short-circuit the + * Returns null on success, or when failedValidation() lets the controller + * handle the failure. Returns a ResponseInterface to short-circuit the * request when authorization or validation fails. * * Do not call this method directly unless you are inside a ``_remap()`` diff --git a/tests/_support/Controllers/FormRequestController.php b/tests/_support/Controllers/FormRequestController.php index bdc3670a8c6a..bed69650fb75 100644 --- a/tests/_support/Controllers/FormRequestController.php +++ b/tests/_support/Controllers/FormRequestController.php @@ -14,6 +14,8 @@ namespace Tests\Support\Controllers; use CodeIgniter\Controller; +use CodeIgniter\HTTP\ResponseInterface; +use Tests\Support\HTTP\Requests\ContinuingPostFormRequest; use Tests\Support\HTTP\Requests\UnauthorizedFormRequest; use Tests\Support\HTTP\Requests\ValidPostFormRequest; @@ -39,6 +41,22 @@ public function store(ValidPostFormRequest $request): string return json_encode($request->getValidated()); } + /** + * Handles validation failures in the controller. + */ + public function storeContinuing(ContinuingPostFormRequest $request): ResponseInterface + { + if ($request->errors !== []) { + return $this->response->setStatusCode(422)->setJSON([ + 'errors' => $request->errors, + 'form' => $request->form, + 'validated' => $request->getValidated(), + ]); + } + + return $this->response->setJSON(['validated' => $request->getValidated()]); + } + /** * Receives a route param alongside a FormRequest. */ diff --git a/tests/_support/HTTP/Requests/ContinuingPostFormRequest.php b/tests/_support/HTTP/Requests/ContinuingPostFormRequest.php new file mode 100644 index 000000000000..517d9a2c488c --- /dev/null +++ b/tests/_support/HTTP/Requests/ContinuingPostFormRequest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\HTTP\Requests; + +use CodeIgniter\HTTP\FormRequest; +use CodeIgniter\HTTP\ResponseInterface; + +/** + * A FormRequest that lets validation failures reach the controller. + */ +class ContinuingPostFormRequest extends FormRequest +{ + /** + * @var array + */ + public array $errors = []; + + /** + * @var array + */ + public array $form = []; + + public function rules(): array + { + return [ + 'title' => 'required|min_length[3]', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + if (isset($data['title'])) { + $data['title'] = trim((string) $data['title']); + } + + return $data; + } + + protected function failedValidation(array $errors, array $preparedData): ?ResponseInterface + { + $this->errors = $errors; + $this->form = $preparedData; + + return null; + } +} diff --git a/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php b/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php index 84f2106ccc21..b8d4a650cbab 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouteCollectorTest.php @@ -52,6 +52,12 @@ public function testGet(): void '', '\Tests\Support\Controllers\FormRequestController::store', ], + [ + 'auto', + 'formRequestController/storeContinuing[/...]', + '', + '\Tests\Support\Controllers\FormRequestController::storeContinuing', + ], [ 'auto', 'formRequestController/update[/...]', diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index d517b4cbb2bf..c0286dcd93c2 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -395,6 +395,116 @@ public function testResolveRequestRedirectsForWildcardAcceptHeader(): void $this->assertSame(303, $response->getStatusCode()); } + public function testFailedValidationMayContinueToController(): void + { + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + /** + * @var array + */ + public array $errors = []; + + /** + * @var array + */ + public array $preparedData = []; + + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + + protected function failedValidation(array $errors, array $preparedData): ?ResponseInterface + { + $this->errors = $errors; + $this->preparedData = $preparedData; + + return null; + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertNotInstanceOf(ResponseInterface::class, $response); + $this->assertArrayHasKey('body', $formRequest->errors); + $this->assertSame(['title' => 'Hello World'], $formRequest->preparedData); + $this->assertSame([], $formRequest->getValidated()); + } + + public function testNullableFailedValidationFormRequestPassesWithValidData(): void + { + service('superglobals')->setPost('title', ' Hello World '); + service('superglobals')->setPost('body', 'Some body text'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public bool $failedValidationCalled = false; + + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + + protected function failedValidation(array $errors, array $preparedData): ?ResponseInterface + { + $this->failedValidationCalled = true; + + return null; + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertNotInstanceOf(ResponseInterface::class, $response); + $this->assertFalse($formRequest->failedValidationCalled); + $this->assertSame(['title' => 'Hello World', 'body' => 'Some body text'], $formRequest->getValidated()); + } + + public function testFailedValidationReturningResponseStillShortCircuits(): void + { + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public static bool $called = false; + + public function rules(): array + { + return ['title' => 'required']; + } + + protected function failedValidation(array $errors, array $preparedData): ResponseInterface + { + self::$called = true; + + return service('response')->setStatusCode(422); + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertTrue($formRequest::$called); + } + public function testPreparedValidationDataIsPassedToFailedValidationWithoutPreparingAgain(): void { service('superglobals')->setPost('title', ' Hello World '); @@ -590,6 +700,36 @@ protected function prepareForValidation(array $data): array $this->assertSame(['authorize'], $formRequest::$order); } + public function testUnauthorizedRequestStopsBeforeNullableFailedValidation(): void + { + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public bool $failedValidationCalled = false; + + public function rules(): array + { + return ['title' => 'required']; + } + + public function isAuthorized(): bool + { + return false; + } + + protected function failedValidation(array $errors, array $preparedData): ?ResponseInterface + { + $this->failedValidationCalled = true; + + return null; + } + }; + + $response = $formRequest->resolveRequest(); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(403, $response->getStatusCode()); + $this->assertFalse($formRequest->failedValidationCalled); + } + // ------------------------------------------------------------------------- // Custom overrides // ------------------------------------------------------------------------- @@ -895,6 +1035,25 @@ public function testInvalidFormRequestRedirectsForAjaxRequest(): void $this->assertSame(303, $response->getStatusCode()); } + // ------------------------------------------------------------------------- + // Integration: controller-handled validation failure + // ------------------------------------------------------------------------- + + #[RunInSeparateProcess] + public function testContinuingInvalidFormRequestReachesController(): void + { + service('superglobals')->setPost('title', ' Draft '); + + $response = $this->runRequest('/posts/continuing', 'storeContinuing', 'POST'); + + $this->assertSame(422, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('body', $body['errors']); + $this->assertSame(['title' => 'Draft'], $body['form']); + $this->assertSame([], $body['validated']); + } + // ------------------------------------------------------------------------- // Integration: authorization failure // ------------------------------------------------------------------------- diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index 278703cd237e..67ea0dc40b7d 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -41,7 +41,8 @@ framework instantiates it, runs authorization and validation, and passes the resolved object to your method. If validation fails, the default behavior redirects back with errors for web requests or returns a 422 JSON response for JSON request bodies or requests that prefer ``application/json`` - the method -body is never reached. +body is never reached unless the FormRequest explicitly continues on validation +failure. .. literalinclude:: form_requests/002.php :lines: 2- @@ -127,12 +128,16 @@ use ``_remap()``. Because ``_remap()`` has a fixed signature inject a FormRequest into. Instantiate the FormRequest manually inside ``_remap()`` and call -``resolveRequest()`` yourself. The method returns ``null`` on success or a -``ResponseInterface`` when authorization or validation fails: +``resolveRequest()`` yourself. The method returns ``null`` on success, ``null`` +when ``failedValidation()`` returns ``null``, or a +``ResponseInterface`` when authorization or validation short-circuits: .. literalinclude:: form_requests/012.php :lines: 2- +If ``failedValidation()`` can return ``null``, check your request's failure +state before using ``getValidated()``. + ********************* Custom Error Messages ********************* @@ -203,8 +208,8 @@ Customizing Failure Behavior **************************** Override ``failedValidation()`` and ``failedAuthorization()`` to take full -control of what happens when a request is rejected. Both methods return a -``ResponseInterface`` that the framework sends to the client: +control of what happens when a request is rejected. Return a +``ResponseInterface`` to send a response immediately: .. literalinclude:: form_requests/008.php :lines: 2- @@ -215,8 +220,30 @@ header. Otherwise, it redirects back with input and validation errors. .. note:: The ``X-Requested-With: XMLHttpRequest`` header alone does not select a JSON response. If an AJAX client expects JSON validation errors, send an - ``Accept: application/json`` header. If your application needs HTML - fragments for AJAX form failures, override ``failedValidation()``. + ``Accept: application/json`` header. If the FormRequest should own the + failed response, override ``failedValidation()``. If the controller should + render the failed response with route context, use the controller-handled + failure flow below. + +Handling Validation Failures in the Controller +============================================== + +Some server-rendered forms need the controller action to render the invalid +response. For example, the failed response may need the same page, panel, or +route context that the controller already owns. + +Override ``failedValidation()`` and return ``null`` to let the controller handle +the failed validation. Store any error or prepared data your controller needs on +the request class: + +.. literalinclude:: form_requests/016.php + :lines: 2- + +The ``$preparedData`` argument contains the values that were passed to +validation. The prepared validation data has not passed validation. Use +``getValidated()`` or ``getValidatedInput()`` only after validation succeeds. + +.. note:: Authorization failures still stop dispatch before the controller runs. .. _form-request-flash-normalized: @@ -253,13 +280,15 @@ whose type extends ``FormRequest``: #. ``prepareForValidation()`` receives that data and may modify it before the rules are applied. #. ``run()`` executes the validation rules. If it fails, ``failedValidation()`` - is called, and its response is returned to the client. -#. The validated data is stored internally and available via ``getValidated()`` - and ``getValidatedInput()``. + is called. If it returns a response, that response is returned to the client. + If it returns ``null``, the failed FormRequest is injected instead. +#. When validation succeeds, the validated data is stored internally and + available via ``getValidated()`` and ``getValidatedInput()``. #. The resolved FormRequest object is injected into the controller method or closure. -The callable is never invoked if authorization or validation fails. Non-FormRequest +The callable is never invoked if authorization fails. Validation failures only +reach the callable when ``failedValidation()`` returns ``null``. Non-FormRequest parameters consume URI route segments in declaration order; variadic parameters receive all remaining segments. diff --git a/user_guide_src/source/incoming/form_requests/016.php b/user_guide_src/source/incoming/form_requests/016.php new file mode 100644 index 000000000000..c56c97331545 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/016.php @@ -0,0 +1,61 @@ + + */ + public array $errors = []; + + /** + * @var array + */ + public array $form = []; + + public function rules(): array + { + return [ + 'title' => 'required|min_length[3]', + 'body' => 'required', + ]; + } + + protected function failedValidation(array $errors, array $preparedData): ?ResponseInterface + { + $this->errors = $errors; + $this->form = $preparedData; + + return null; + } +} + +namespace App\Controllers; + +use App\Requests\StorePostRequest; +use CodeIgniter\HTTP\ResponseInterface; + +class Posts extends BaseController +{ + public function create(StorePostRequest $request): ResponseInterface + { + if ($request->errors !== []) { + return $this->response + ->setStatusCode(422) + ->setBody(view('posts/new', [ + 'form' => $request->form, + 'errors' => $request->errors, + ])); + } + + $data = $request->getValidated(); + + // Save the post... + + return redirect()->to('/posts'); + } +}