Skip to content
Open
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
118 changes: 113 additions & 5 deletions system/CodeIgniter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +38,9 @@
use Config\Feature;
use Config\Services;
use Locale;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use Throwable;

/**
Expand Down Expand Up @@ -104,7 +109,7 @@ class CodeIgniter
/**
* Controller to use.
*
* @var (Closure(mixed...): ResponseInterface|string)|string|null
* @var Closure|string|null
*/
protected $controller;

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

return $controller(...$this->router->params());
return $controller(...$resolved);
}

// No controller specified - we don't know what to do now.
Expand Down Expand Up @@ -662,15 +668,117 @@ 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);
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);
}

$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.
* - 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) {
// Inject FormRequest subclasses regardless of position.
$formRequestClass = FormRequest::getFormRequestClass($param);

if ($formRequestClass !== null) {
$resolved[] = $this->resolveFormRequest($formRequestClass);

continue;
}

// Variadic parameter - consume all remaining route segments.
if ($param->isVariadic()) {
while (array_key_exists($routeIndex, $routeParams)) {
$resolved[] = $routeParams[$routeIndex++];
}

break;
}

// Consume the next route segment if one is available.
if (array_key_exists($routeIndex, $routeParams)) {
$resolved[] = $routeParams[$routeIndex++];

continue;
}

// 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;
}
}

return $resolved;
}

/**
* 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);
$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.
Expand Down
86 changes: 86 additions & 0 deletions system/Commands/Generators/FormRequestGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?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);
}
}
38 changes: 38 additions & 0 deletions system/Commands/Generators/Views/formrequest.tpl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<@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>|string>
// */
// public function messages(): array
// {
// return [];
// }

// /**
// * Determines if the current user is authorized to make this request.
// */
// public function isAuthorized(): bool
// {
// return true;
// }
}
34 changes: 34 additions & 0 deletions system/HTTP/Exceptions/FormRequestException.php
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
{
public function __construct(private readonly ResponseInterface $response)
{
parent::__construct('FormRequest authorization or validation failed.');
}

public function getResponse(): ResponseInterface
{
return $this->response;
}
}
Loading
Loading