Skip to content

Commit e85e829

Browse files
committed
feat: add FormRequest for encapsulating validation and authorization
1 parent 399983b commit e85e829

39 files changed

+2159
-50
lines changed

system/CodeIgniter.php

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
use CodeIgniter\Exceptions\PageNotFoundException;
2020
use CodeIgniter\Filters\Filters;
2121
use CodeIgniter\HTTP\CLIRequest;
22+
use CodeIgniter\HTTP\Exceptions\FormRequestException;
2223
use CodeIgniter\HTTP\Exceptions\RedirectException;
24+
use CodeIgniter\HTTP\FormRequest;
2325
use CodeIgniter\HTTP\IncomingRequest;
2426
use CodeIgniter\HTTP\Method;
2527
use CodeIgniter\HTTP\NonBufferedResponseInterface;
@@ -36,6 +38,9 @@
3638
use Config\Feature;
3739
use Config\Services;
3840
use Locale;
41+
use ReflectionFunction;
42+
use ReflectionFunctionAbstract;
43+
use ReflectionMethod;
3944
use Throwable;
4045

4146
/**
@@ -104,7 +109,7 @@ class CodeIgniter
104109
/**
105110
* Controller to use.
106111
*
107-
* @var (Closure(mixed...): ResponseInterface|string)|string|null
112+
* @var Closure|string|null
108113
*/
109114
protected $controller;
110115

@@ -584,8 +589,9 @@ protected function startController()
584589
// Is it routed to a Closure?
585590
if (is_object($this->controller) && ($this->controller::class === 'Closure')) {
586591
$controller = $this->controller;
592+
$resolved = $this->resolveCallableParams(new ReflectionFunction($controller), $this->router->params());
587593

588-
return $controller(...$this->router->params());
594+
return $controller(...$resolved);
589595
}
590596

591597
// No controller specified - we don't know what to do now.
@@ -662,15 +668,117 @@ protected function runController($class)
662668

663669
// The controller method param types may not be string.
664670
// So cannot set `declare(strict_types=1)` in this file.
665-
$output = method_exists($class, '_remap')
666-
? $class->_remap($this->method, ...$params)
667-
: $class->{$this->method}(...$params);
671+
if (method_exists($class, '_remap')) {
672+
// FormRequest injection is not supported for _remap() because its
673+
// signature is fixed to ($method, ...$params). Instantiate the
674+
// FormRequest manually inside _remap() if needed.
675+
$output = $class->_remap($this->method, ...$params);
676+
} else {
677+
$resolved = $this->resolveMethodParams($class, $this->method, $params);
678+
$output = $class->{$this->method}(...$resolved);
679+
}
668680

669681
$this->benchmark->stop('controller');
670682

671683
return $output;
672684
}
673685

686+
/**
687+
* Resolves the final parameter list for a controller method call.
688+
*
689+
* @param list<string> $routeParams URI segments from the router.
690+
*
691+
* @return list<mixed>
692+
*/
693+
private function resolveMethodParams(object $class, string $method, array $routeParams): array
694+
{
695+
return $this->resolveCallableParams(new ReflectionMethod($class, $method), $routeParams);
696+
}
697+
698+
/**
699+
* Shared FormRequest resolver for both controller methods and closures.
700+
*
701+
* Builds a sequential positional argument list for the call site.
702+
* The supported signature shape is: required scalar route params first,
703+
* then the FormRequest, then optional scalar params.
704+
*
705+
* - FormRequest subclasses are instantiated, authorized, and validated
706+
* before being injected.
707+
* - Variadic non-FormRequest parameters consume all remaining URI segments.
708+
* - Scalar non-FormRequest parameters consume one URI segment each.
709+
* - When route segments run out, a required non-FormRequest parameter stops
710+
* iteration so PHP throws an ArgumentCountError on the call site.
711+
* - Optional non-FormRequest parameters with no remaining segment are omitted
712+
* from the list; PHP then applies their declared default values.
713+
*
714+
* @param list<string> $routeParams URI segments from the router.
715+
*
716+
* @return list<mixed>
717+
*/
718+
private function resolveCallableParams(ReflectionFunctionAbstract $reflection, array $routeParams): array
719+
{
720+
$resolved = [];
721+
$routeIndex = 0;
722+
723+
foreach ($reflection->getParameters() as $param) {
724+
// Inject FormRequest subclasses regardless of position.
725+
$formRequestClass = FormRequest::getFormRequestClass($param);
726+
727+
if ($formRequestClass !== null) {
728+
$resolved[] = $this->resolveFormRequest($formRequestClass);
729+
730+
continue;
731+
}
732+
733+
// Variadic parameter - consume all remaining route segments.
734+
if ($param->isVariadic()) {
735+
while (array_key_exists($routeIndex, $routeParams)) {
736+
$resolved[] = $routeParams[$routeIndex++];
737+
}
738+
739+
break;
740+
}
741+
742+
// Consume the next route segment if one is available.
743+
if (array_key_exists($routeIndex, $routeParams)) {
744+
$resolved[] = $routeParams[$routeIndex++];
745+
746+
continue;
747+
}
748+
749+
// No more route segments. Required params stop iteration so that
750+
// PHP throws an ArgumentCountError on the call site. Optional
751+
// params are omitted - PHP then applies their declared default value.
752+
if (! $param->isOptional()) {
753+
break;
754+
}
755+
}
756+
757+
return $resolved;
758+
}
759+
760+
/**
761+
* Instantiates, authorizes, and validates a FormRequest class.
762+
*
763+
* If authorization or validation fails, the FormRequest returns a
764+
* ResponseInterface. The framework wraps it in a FormRequestException
765+
* (which implements ResponsableInterface) so the response is sent
766+
* without reaching the controller method.
767+
*
768+
* @param class-string<FormRequest> $className
769+
*/
770+
private function resolveFormRequest(string $className): FormRequest
771+
{
772+
$formRequest = new $className($this->request);
773+
$response = $formRequest->resolveRequest();
774+
775+
if ($response !== null) {
776+
throw new FormRequestException($response);
777+
}
778+
779+
return $formRequest;
780+
}
781+
674782
/**
675783
* Displays a 404 Page Not Found error. If set, will try to
676784
* call the 404Override controller/method that was set in routing config.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Commands\Generators;
15+
16+
use CodeIgniter\CLI\BaseCommand;
17+
use CodeIgniter\CLI\GeneratorTrait;
18+
19+
/**
20+
* Generates a skeleton FormRequest file.
21+
*/
22+
class FormRequestGenerator extends BaseCommand
23+
{
24+
use GeneratorTrait;
25+
26+
/**
27+
* The Command's Group
28+
*
29+
* @var string
30+
*/
31+
protected $group = 'Generators';
32+
33+
/**
34+
* The Command's Name
35+
*
36+
* @var string
37+
*/
38+
protected $name = 'make:request';
39+
40+
/**
41+
* The Command's Description
42+
*
43+
* @var string
44+
*/
45+
protected $description = 'Generates a new FormRequest file.';
46+
47+
/**
48+
* The Command's Usage
49+
*
50+
* @var string
51+
*/
52+
protected $usage = 'make:request <name> [options]';
53+
54+
/**
55+
* The Command's Arguments
56+
*
57+
* @var array<string, string>
58+
*/
59+
protected $arguments = [
60+
'name' => 'The FormRequest class name.',
61+
];
62+
63+
/**
64+
* The Command's Options
65+
*
66+
* @var array<string, string>
67+
*/
68+
protected $options = [
69+
'--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
70+
'--suffix' => 'Append the component title to the class name (e.g. User => UserRequest).',
71+
'--force' => 'Force overwrite existing file.',
72+
];
73+
74+
/**
75+
* Actually execute a command.
76+
*/
77+
public function run(array $params)
78+
{
79+
$this->component = 'Request';
80+
$this->directory = 'Requests';
81+
$this->template = 'formrequest.tpl.php';
82+
83+
$this->classNameLang = 'CLI.generator.className.request';
84+
$this->generateClass($params);
85+
}
86+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<@php
2+
3+
namespace {namespace};
4+
5+
use CodeIgniter\HTTP\FormRequest;
6+
7+
class {class} extends FormRequest
8+
{
9+
/**
10+
* Returns the validation rules that apply to this request.
11+
*
12+
* @return array<string, list<string>|string>
13+
*/
14+
public function rules(): array
15+
{
16+
return [
17+
// 'field' => 'required',
18+
];
19+
}
20+
21+
// /**
22+
// * Custom error messages keyed by field.rule.
23+
// *
24+
// * @return array<string, array<string, string>|string>
25+
// */
26+
// public function messages(): array
27+
// {
28+
// return [];
29+
// }
30+
31+
// /**
32+
// * Determines if the current user is authorized to make this request.
33+
// */
34+
// public function authorize(): bool
35+
// {
36+
// return true;
37+
// }
38+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\HTTP\Exceptions;
15+
16+
use CodeIgniter\Exceptions\RuntimeException;
17+
use CodeIgniter\HTTP\ResponsableInterface;
18+
use CodeIgniter\HTTP\ResponseInterface;
19+
20+
/**
21+
* @internal
22+
*/
23+
final class FormRequestException extends RuntimeException implements ResponsableInterface
24+
{
25+
public function __construct(private readonly ResponseInterface $response)
26+
{
27+
parent::__construct('FormRequest authorization or validation failed.');
28+
}
29+
30+
public function getResponse(): ResponseInterface
31+
{
32+
return $this->response;
33+
}
34+
}

0 commit comments

Comments
 (0)