Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 2k
feat: add FormRequest for encapsulating validation and authorization#10087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
c0f5ebceee73cfd4b0fe2ac64198038019fa3f5440a7d3d0653be314c7b87bc6bc7eced6760856bf52b126d6898c49b473d2717adFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -19,7 +19,9 @@ | ||
| use CodeIgniter\Exceptions\PageNotFoundException; | ||
| use CodeIgniter\Filters\Filters; | ||
| use CodeIgniter\HTTP\CLIRequest; | ||
| use CodeIgniter\HTTP\Exceptions\FormRequestException; | ||
| use CodeIgniter\HTTP\Exceptions\RedirectException; | ||
| use CodeIgniter\HTTP\FormRequest; | ||
| use CodeIgniter\HTTP\IncomingRequest; | ||
| use CodeIgniter\HTTP\Method; | ||
| use CodeIgniter\HTTP\NonBufferedResponseInterface; | ||
| @@ -29,13 +31,18 @@ | ||
| use CodeIgniter\HTTP\ResponsableInterface; | ||
| use CodeIgniter\HTTP\ResponseInterface; | ||
| use CodeIgniter\HTTP\URI; | ||
| use CodeIgniter\Router\CallableParamClassifier; | ||
| use CodeIgniter\Router\ParamKind; | ||
| use CodeIgniter\Router\RouteCollectionInterface; | ||
| use CodeIgniter\Router\Router; | ||
| use Config\App; | ||
| use Config\Cache; | ||
| use Config\Feature; | ||
| use Config\Services; | ||
| use Locale; | ||
| use ReflectionFunction; | ||
| use ReflectionFunctionAbstract; | ||
| use ReflectionMethod; | ||
| use Throwable; | ||
| /** | ||
| @@ -104,7 +111,7 @@ class CodeIgniter | ||
| /** | ||
| * Controller to use. | ||
| * | ||
| * @var (Closure(mixed...): ResponseInterface|string)|string|null | ||
| * @var Closure|string|null | ||
| */ | ||
| protected $controller; | ||
| @@ -375,7 +382,8 @@ protected function handleRequest(?RouteCollectionInterface $routes, ?Cache $cach | ||
| if ($returned instanceof ResponseInterface) { | ||
| $this->gatherOutput($returned); | ||
| } | ||
| // Closure controller has run in startController(). | ||
| // Closure controller has run in startController() - benchmarks were | ||
| // stopped there as well. | ||
| elseif (! is_callable($this->controller)) { | ||
| $controller = $this->createController(); | ||
| @@ -387,9 +395,6 @@ protected function handleRequest(?RouteCollectionInterface $routes, ?Cache $cach | ||
| Events::trigger('post_controller_constructor'); | ||
| $returned = $this->runController($controller); | ||
| } else { | ||
| $this->benchmark->stop('controller_constructor'); | ||
| $this->benchmark->stop('controller'); | ||
| } | ||
| // If $returned is a string, then the controller output something, | ||
| @@ -585,7 +590,14 @@ protected function startController() | ||
| if (is_object($this->controller) && ($this->controller::class === 'Closure')) { | ||
| $controller = $this->controller; | ||
| return $controller(...$this->router->params()); | ||
| try { | ||
| $resolved = $this->resolveCallableParams(new ReflectionFunction($controller), $this->router->params()); | ||
| return $controller(...$resolved); | ||
| } finally { | ||
| $this->benchmark->stop('controller_constructor'); | ||
| $this->benchmark->stop('controller'); | ||
| } | ||
| } | ||
| // No controller specified - we don't know what to do now. | ||
| @@ -662,15 +674,120 @@ protected function runController($class) | ||
| // The controller method param types may not be string. | ||
| // So cannot set `declare(strict_types=1)` in this file. | ||
| $output = method_exists($class, '_remap') | ||
| ? $class->_remap($this->method, ...$params) | ||
| : $class->{$this->method}(...$params); | ||
| $this->benchmark->stop('controller'); | ||
| try { | ||
| if (method_exists($class, '_remap')) { | ||
| // FormRequest injection is not supported for _remap() because its | ||
| // signature is fixed to ($method, ...$params). Instantiate the | ||
| // FormRequest manually inside _remap() if needed. | ||
| $output = $class->_remap($this->method, ...$params); | ||
| } else { | ||
| $resolved = $this->resolveMethodParams($class, $this->method, $params); | ||
| $output = $class->{$this->method}(...$resolved); | ||
| } | ||
| } finally { | ||
| $this->benchmark->stop('controller'); | ||
| } | ||
| return $output; | ||
| } | ||
| /** | ||
| * Resolves the final parameter list for a controller method call. | ||
| * | ||
| * @param list<string> $routeParams URI segments from the router. | ||
| * | ||
| * @return list<mixed> | ||
| */ | ||
| private function resolveMethodParams(object $class, string $method, array $routeParams): array | ||
| { | ||
| return $this->resolveCallableParams(new ReflectionMethod($class, $method), $routeParams); | ||
| } | ||
| /** | ||
| * Shared FormRequest resolver for both controller methods and closures. | ||
| * | ||
| * Builds a sequential positional argument list for the call site. | ||
| * The supported signature shape is: required scalar route params first, | ||
| * then the FormRequest, then optional scalar params. | ||
| * | ||
| * - FormRequest subclasses are instantiated, authorized, and validated | ||
| * before being injected. | ||
michalsn marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| * - Variadic non-FormRequest parameters consume all remaining URI segments. | ||
| * - Scalar non-FormRequest parameters consume one URI segment each. | ||
| * - When route segments run out, a required non-FormRequest parameter stops | ||
| * iteration so PHP throws an ArgumentCountError on the call site. | ||
| * - Optional non-FormRequest parameters with no remaining segment are omitted | ||
| * from the list; PHP then applies their declared default values. | ||
| * | ||
| * @param list<string> $routeParams URI segments from the router. | ||
| * | ||
| * @return list<mixed> | ||
| */ | ||
| private function resolveCallableParams(ReflectionFunctionAbstract $reflection, array $routeParams): array | ||
| { | ||
| $resolved = []; | ||
| $routeIndex = 0; | ||
| foreach ($reflection->getParameters() as $param) { | ||
| [$kind, $formRequestClass] = CallableParamClassifier::classify($param); | ||
| switch ($kind) { | ||
| case ParamKind::FormRequest: | ||
| // Inject FormRequest subclasses regardless of position. | ||
| $resolved[] = $this->resolveFormRequest($formRequestClass); | ||
| continue 2; | ||
| case ParamKind::Variadic: | ||
| // Consume all remaining route segments. | ||
| while (array_key_exists($routeIndex, $routeParams)) { | ||
| $resolved[] = $routeParams[$routeIndex++]; | ||
| } | ||
| break 2; | ||
| case ParamKind::Scalar: | ||
| // Consume the next route segment if one is available. | ||
| if (array_key_exists($routeIndex, $routeParams)) { | ||
| $resolved[] = $routeParams[$routeIndex++]; | ||
| continue 2; | ||
| } | ||
| // No more route segments. Required params stop iteration so | ||
| // that PHP throws an ArgumentCountError on the call site. | ||
| // Optional params are omitted - PHP then applies their | ||
| // declared default value. | ||
| if (! $param->isOptional()) { | ||
| break 2; | ||
| } | ||
| } | ||
| } | ||
| return $resolved; | ||
michalsn marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| /** | ||
| * 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. | ||
| * | ||
| * @param class-string<FormRequest> $className | ||
| */ | ||
| private function resolveFormRequest(string $className): FormRequest | ||
| { | ||
| $formRequest = new $className($this->request); | ||
paulbalandan marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| $response = $formRequest->resolveRequest(); | ||
| if ($response !== null) { | ||
| throw new FormRequestException($response); | ||
| } | ||
| return $formRequest; | ||
| } | ||
| /** | ||
| * Displays a 404 Page Not Found error. If set, will try to | ||
| * call the 404Override controller/method that was set in routing config. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| <?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 CodeIgniter\Commands\Generators; | ||
| use CodeIgniter\CLI\BaseCommand; | ||
| use CodeIgniter\CLI\GeneratorTrait; | ||
| /** | ||
| * Generates a skeleton FormRequest file. | ||
| */ | ||
| class FormRequestGenerator extends BaseCommand | ||
| { | ||
| use GeneratorTrait; | ||
| /** | ||
| * The Command's Group | ||
| * | ||
| * @var string | ||
| */ | ||
| protected $group = 'Generators'; | ||
| /** | ||
| * The Command's Name | ||
| * | ||
| * @var string | ||
| */ | ||
| protected $name = 'make:request'; | ||
| /** | ||
| * The Command's Description | ||
| * | ||
| * @var string | ||
| */ | ||
| protected $description = 'Generates a new FormRequest file.'; | ||
| /** | ||
| * The Command's Usage | ||
| * | ||
| * @var string | ||
| */ | ||
| protected $usage = 'make:request <name> [options]'; | ||
| /** | ||
| * The Command's Arguments | ||
| * | ||
| * @var array<string, string> | ||
| */ | ||
| protected $arguments = [ | ||
| 'name' => 'The FormRequest class name.', | ||
| ]; | ||
| /** | ||
| * The Command's Options | ||
| * | ||
| * @var array<string, string> | ||
| */ | ||
| protected $options = [ | ||
| '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', | ||
| '--suffix' => 'Append the component title to the class name (e.g. User => UserRequest).', | ||
| '--force' => 'Force overwrite existing file.', | ||
| ]; | ||
| /** | ||
| * Actually execute a command. | ||
| */ | ||
| public function run(array $params) | ||
| { | ||
| $this->component = 'Request'; | ||
| $this->directory = 'Requests'; | ||
| $this->template = 'formrequest.tpl.php'; | ||
| $this->classNameLang = 'CLI.generator.className.request'; | ||
| $this->generateClass($params); | ||
| return EXIT_SUCCESS; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| <@php | ||
| namespace {namespace}; | ||
| use CodeIgniter\HTTP\FormRequest; | ||
| class {class} extends FormRequest | ||
| { | ||
| /** | ||
| * Returns the validation rules that apply to this request. | ||
| * | ||
| * @return array<string, list<string>|string> | ||
| */ | ||
| public function rules(): array | ||
| { | ||
| return [ | ||
| // 'field' => 'required', | ||
| ]; | ||
| } | ||
| // /** | ||
| // * Custom error messages keyed by field.rule. | ||
| // * | ||
| // * @return array<string, array<string, string>> | ||
| // */ | ||
| // public function messages(): array | ||
| // { | ||
| // return []; | ||
| // } | ||
| // /** | ||
| // * Determines if the current user is authorized to make this request. | ||
paulbalandan marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // * | ||
| // * Defaults to true in FormRequest. Override only when authorization | ||
| // * depends on application logic. | ||
| // */ | ||
| // public function isAuthorized(): bool | ||
| // { | ||
| // return true; | ||
| // } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| <?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 CodeIgniter\HTTP\Exceptions; | ||
| use CodeIgniter\Exceptions\RuntimeException; | ||
| use CodeIgniter\HTTP\ResponsableInterface; | ||
| use CodeIgniter\HTTP\ResponseInterface; | ||
| /** | ||
| * @internal | ||
| */ | ||
| final class FormRequestException extends RuntimeException implements ResponsableInterface | ||
michalsn marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| { | ||
| public function __construct(private readonly ResponseInterface $response) | ||
| { | ||
| parent::__construct('FormRequest authorization or validation failed.'); | ||
paulbalandan marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| public function getResponse(): ResponseInterface | ||
| { | ||
| return $this->response; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.