Skip to content

Commit 80f8cd8

Browse files
feat: add FormRequest for encapsulating validation and authorization (#10087)
* feat: add FormRequest for encapsulating validation and authorization * fix tests * apply code suggestions Co-authored-by: John Paul E. Balandan, CPA <paulbalandan@gmail.com> * replace FormRequest magic field access with explicit validated accessors * add resetServices() in the tearDown() method * return proper exit code in FormRequestGenerator * phpstan baseline update * remove internal doc block * add tests for formrequest generator * add a check for the request instance in FormRequest * rector fix * fix FormRequest contructor * fix: support variadic FormRequest auto-routes and closure benchmark cleanup * refactor: share callable param classification between router and dispatcher * mark FormRequest::getFormRequestClass() as final --------- Co-authored-by: John Paul E. Balandan, CPA <paulbalandan@gmail.com>
1 parent d0607f1 commit 80f8cd8

44 files changed

Lines changed: 2470 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

system/CodeIgniter.php

Lines changed: 128 additions & 11 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;
@@ -29,13 +31,18 @@
2931
use CodeIgniter\HTTP\ResponsableInterface;
3032
use CodeIgniter\HTTP\ResponseInterface;
3133
use CodeIgniter\HTTP\URI;
34+
use CodeIgniter\Router\CallableParamClassifier;
35+
use CodeIgniter\Router\ParamKind;
3236
use CodeIgniter\Router\RouteCollectionInterface;
3337
use CodeIgniter\Router\Router;
3438
use Config\App;
3539
use Config\Cache;
3640
use Config\Feature;
3741
use Config\Services;
3842
use Locale;
43+
use ReflectionFunction;
44+
use ReflectionFunctionAbstract;
45+
use ReflectionMethod;
3946
use Throwable;
4047

4148
/**
@@ -104,7 +111,7 @@ class CodeIgniter
104111
/**
105112
* Controller to use.
106113
*
107-
* @var (Closure(mixed...): ResponseInterface|string)|string|null
114+
* @var Closure|string|null
108115
*/
109116
protected $controller;
110117

@@ -375,7 +382,8 @@ protected function handleRequest(?RouteCollectionInterface $routes, ?Cache $cach
375382
if ($returned instanceof ResponseInterface) {
376383
$this->gatherOutput($returned);
377384
}
378-
// Closure controller has run in startController().
385+
// Closure controller has run in startController() - benchmarks were
386+
// stopped there as well.
379387
elseif (! is_callable($this->controller)) {
380388
$controller = $this->createController();
381389

@@ -387,9 +395,6 @@ protected function handleRequest(?RouteCollectionInterface $routes, ?Cache $cach
387395
Events::trigger('post_controller_constructor');
388396

389397
$returned = $this->runController($controller);
390-
} else {
391-
$this->benchmark->stop('controller_constructor');
392-
$this->benchmark->stop('controller');
393398
}
394399

395400
// If $returned is a string, then the controller output something,
@@ -585,7 +590,14 @@ protected function startController()
585590
if (is_object($this->controller) && ($this->controller::class === 'Closure')) {
586591
$controller = $this->controller;
587592

588-
return $controller(...$this->router->params());
593+
try {
594+
$resolved = $this->resolveCallableParams(new ReflectionFunction($controller), $this->router->params());
595+
596+
return $controller(...$resolved);
597+
} finally {
598+
$this->benchmark->stop('controller_constructor');
599+
$this->benchmark->stop('controller');
600+
}
589601
}
590602

591603
// No controller specified - we don't know what to do now.
@@ -662,15 +674,120 @@ protected function runController($class)
662674

663675
// The controller method param types may not be string.
664676
// 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);
668-
669-
$this->benchmark->stop('controller');
677+
try {
678+
if (method_exists($class, '_remap')) {
679+
// FormRequest injection is not supported for _remap() because its
680+
// signature is fixed to ($method, ...$params). Instantiate the
681+
// FormRequest manually inside _remap() if needed.
682+
$output = $class->_remap($this->method, ...$params);
683+
} else {
684+
$resolved = $this->resolveMethodParams($class, $this->method, $params);
685+
$output = $class->{$this->method}(...$resolved);
686+
}
687+
} finally {
688+
$this->benchmark->stop('controller');
689+
}
670690

671691
return $output;
672692
}
673693

694+
/**
695+
* Resolves the final parameter list for a controller method call.
696+
*
697+
* @param list<string> $routeParams URI segments from the router.
698+
*
699+
* @return list<mixed>
700+
*/
701+
private function resolveMethodParams(object $class, string $method, array $routeParams): array
702+
{
703+
return $this->resolveCallableParams(new ReflectionMethod($class, $method), $routeParams);
704+
}
705+
706+
/**
707+
* Shared FormRequest resolver for both controller methods and closures.
708+
*
709+
* Builds a sequential positional argument list for the call site.
710+
* The supported signature shape is: required scalar route params first,
711+
* then the FormRequest, then optional scalar params.
712+
*
713+
* - FormRequest subclasses are instantiated, authorized, and validated
714+
* before being injected.
715+
* - Variadic non-FormRequest parameters consume all remaining URI segments.
716+
* - Scalar non-FormRequest parameters consume one URI segment each.
717+
* - When route segments run out, a required non-FormRequest parameter stops
718+
* iteration so PHP throws an ArgumentCountError on the call site.
719+
* - Optional non-FormRequest parameters with no remaining segment are omitted
720+
* from the list; PHP then applies their declared default values.
721+
*
722+
* @param list<string> $routeParams URI segments from the router.
723+
*
724+
* @return list<mixed>
725+
*/
726+
private function resolveCallableParams(ReflectionFunctionAbstract $reflection, array $routeParams): array
727+
{
728+
$resolved = [];
729+
$routeIndex = 0;
730+
731+
foreach ($reflection->getParameters() as $param) {
732+
[$kind, $formRequestClass] = CallableParamClassifier::classify($param);
733+
734+
switch ($kind) {
735+
case ParamKind::FormRequest:
736+
// Inject FormRequest subclasses regardless of position.
737+
$resolved[] = $this->resolveFormRequest($formRequestClass);
738+
739+
continue 2;
740+
741+
case ParamKind::Variadic:
742+
// Consume all remaining route segments.
743+
while (array_key_exists($routeIndex, $routeParams)) {
744+
$resolved[] = $routeParams[$routeIndex++];
745+
}
746+
break 2;
747+
748+
case ParamKind::Scalar:
749+
// Consume the next route segment if one is available.
750+
if (array_key_exists($routeIndex, $routeParams)) {
751+
$resolved[] = $routeParams[$routeIndex++];
752+
753+
continue 2;
754+
}
755+
756+
// No more route segments. Required params stop iteration so
757+
// that PHP throws an ArgumentCountError on the call site.
758+
// Optional params are omitted - PHP then applies their
759+
// declared default value.
760+
if (! $param->isOptional()) {
761+
break 2;
762+
}
763+
}
764+
}
765+
766+
return $resolved;
767+
}
768+
769+
/**
770+
* Instantiates, authorizes, and validates a FormRequest class.
771+
*
772+
* If authorization or validation fails, the FormRequest returns a
773+
* ResponseInterface. The framework wraps it in a FormRequestException
774+
* (which implements ResponsableInterface) so the response is sent
775+
* without reaching the controller method.
776+
*
777+
* @param class-string<FormRequest> $className
778+
*/
779+
private function resolveFormRequest(string $className): FormRequest
780+
{
781+
$formRequest = new $className($this->request);
782+
$response = $formRequest->resolveRequest();
783+
784+
if ($response !== null) {
785+
throw new FormRequestException($response);
786+
}
787+
788+
return $formRequest;
789+
}
790+
674791
/**
675792
* Displays a 404 Page Not Found error. If set, will try to
676793
* call the 404Override controller/method that was set in routing config.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
return EXIT_SUCCESS;
87+
}
88+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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>>
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+
// * Defaults to true in FormRequest. Override only when authorization
35+
// * depends on application logic.
36+
// */
37+
// public function isAuthorized(): bool
38+
// {
39+
// return true;
40+
// }
41+
}
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)