Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions system/CodeIgniter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormRequest> $className
*/
Expand Down
10 changes: 7 additions & 3 deletions system/HTTP/FormRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -155,7 +158,7 @@ protected function prepareForValidation(array $data): array
* @param array<string, string> $errors
* @param array<string, mixed> $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]);
Expand Down Expand Up @@ -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()``
Expand Down
18 changes: 18 additions & 0 deletions tests/_support/Controllers/FormRequestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
*/
Expand Down
58 changes: 58 additions & 0 deletions tests/_support/HTTP/Requests/ContinuingPostFormRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string, string>
*/
public array $errors = [];

/**
* @var array<string, mixed>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ public function testGet(): void
'',
'\Tests\Support\Controllers\FormRequestController::store',
],
[
'auto',
'formRequestController/storeContinuing[/...]',
'',
'\Tests\Support\Controllers\FormRequestController::storeContinuing',
],
[
'auto',
'formRequestController/update[/...]',
Expand Down
159 changes: 159 additions & 0 deletions tests/system/HTTP/FormRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
public array $errors = [];

/**
* @var array<string, mixed>
*/
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 ');
Expand Down Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
Loading
Loading