diff --git a/deptrac.yaml b/deptrac.yaml index 955fda2394ee..eb8daa61f03e 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -78,6 +78,10 @@ deptrac: - type: classNameRegex # includes the Filter value: '/^CodeIgniter\\.*Honeypot.*$/' + - name: Input + collectors: + - type: classNameRegex + value: '/^CodeIgniter\\Input\\.*$/' - name: HTTP collectors: - type: bool @@ -248,6 +252,8 @@ deptrac: - I18n Validation: - HTTP + - I18n + - Input - Database View: - Cache @@ -267,6 +273,8 @@ deptrac: - CodeIgniter\Entity\Entity CodeIgniter\Entity\Cast\URICast: - CodeIgniter\HTTP\URI + CodeIgniter\HTTP\FormRequest: + - CodeIgniter\Validation\ValidatedInput CodeIgniter\Log\Handlers\ChromeLoggerHandler: - CodeIgniter\HTTP\ResponseInterface CodeIgniter\Security\CheckPhpIni: diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 6564bddaf948..fbc08282f2b3 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -46,6 +46,7 @@ use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Input\InputData; use CodeIgniter\Language\Language; use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; @@ -58,6 +59,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidatedInput; use CodeIgniter\Validation\ValidationInterface; use CodeIgniter\View\Cell; use CodeIgniter\View\Parser; @@ -120,6 +122,7 @@ * @method static Honeypot honeypot(ConfigHoneyPot $config = null, $getShared = true) * @method static BaseHandler image($handler = null, Images $config = null, $getShared = true) * @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true) + * @method static InputData inputdata(array $data = [], bool $getShared = false) * @method static Iterator iterator($getShared = true) * @method static Language language($locale = null, $getShared = true) * @method static LockManager locks(?CacheInterface $cache = null, bool $getShared = true) @@ -144,6 +147,7 @@ * @method static Toolbar toolbar(ConfigToolbar $config = null, $getShared = true) * @method static Typography typography($getShared = true) * @method static URI uri($uri = null, $getShared = true) + * @method static ValidatedInput validatedinput(array $data = [], bool $getShared = false) * @method static ValidationInterface validation(ConfigValidation $config = null, $getShared = true) * @method static Cell viewcell($getShared = true) */ diff --git a/system/Config/Services.php b/system/Config/Services.php index 51244718e221..3e20d1928669 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -46,6 +46,7 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Input\InputData; use CodeIgniter\Language\Language; use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; @@ -62,6 +63,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidatedInput; use CodeIgniter\Validation\Validation; use CodeIgniter\Validation\ValidationInterface; use CodeIgniter\View\Cell; @@ -387,6 +389,20 @@ public static function image(?string $handler = null, ?Images $config = null, bo return new $class($config); } + /** + * Returns a typed input data object. + * + * @param array $data + */ + public static function inputdata(array $data = [], bool $getShared = false): InputData + { + if ($getShared) { + return static::getSharedInstance('inputdata', $data); + } + + return new InputData($data); + } + /** * The Iterator class provides a simple way of looping over a function * and timing the results and memory usage. Used when debugging and @@ -873,6 +889,20 @@ public static function validation(?ValidationConfig $config = null, bool $getSha return new Validation($config, AppServices::get('renderer')); } + /** + * Returns a typed validated input object. + * + * @param array $data + */ + public static function validatedinput(array $data = [], bool $getShared = false): ValidatedInput + { + if ($getShared) { + return static::getSharedInstance('validatedinput', $data); + } + + return new ValidatedInput($data); + } + /** * View cells are intended to let you insert HTML into view * that has been generated by any callable in the system. diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 5e831e69be5d..e35e002c614b 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Validation\ValidatedInput; use ReflectionNamedType; use ReflectionParameter; @@ -182,6 +183,14 @@ public function validated(): array return $this->validatedData; } + /** + * Returns the validated data as a typed input object. + */ + public function validatedInput(): ValidatedInput + { + return service('validatedinput', $this->validatedData, false); + } + /** * Returns a single validated field value by name, or the default value * if the field is not present in the validated data. diff --git a/system/Input/InputData.php b/system/Input/InputData.php new file mode 100644 index 000000000000..0087ca662039 --- /dev/null +++ b/system/Input/InputData.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * @see \CodeIgniter\Input\InputDataTest + */ +class InputData +{ + /** + * @param array $data + */ + public function __construct(private readonly array $data) + { + } + + /** + * Returns a single input value by name, or the default value if the field + * is not present. + * + * Supports dot-array syntax for nested input data. + */ + public function get(string $key, mixed $default = null): mixed + { + helper('array'); + + if (! dot_array_has($key, $this->data)) { + return $default; + } + + return dot_array_search($key, $this->data); + } + + /** + * Returns true when the named field exists, even if its value is null. + * + * Supports dot-array syntax for nested input data. + */ + public function has(string $key): bool + { + helper('array'); + + return dot_array_has($key, $this->data); + } + + /** + * Returns an input field as a string. + * + * Supports dot-array syntax for nested input data. + */ + public function string(string $key, ?string $default = null): ?string + { + $value = $this->get($key, $default); + + if ($value === null || is_string($value)) { + return $value; + } + + throw $this->invalidType($key, 'string'); + } + + /** + * Returns an input field as an integer. + * + * Supports dot-array syntax for nested input data. + */ + public function integer(string $key, ?int $default = null): ?int + { + $value = $this->get($key, $default); + + if ($value === null || is_int($value)) { + return $value; + } + + if (is_string($value)) { + $integer = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + if ($integer !== null) { + return $integer; + } + } + + throw $this->invalidType($key, 'integer'); + } + + /** + * Returns an input field as a float. + * + * Supports dot-array syntax for nested input data. + */ + public function float(string $key, ?float $default = null): ?float + { + $value = $this->get($key, $default); + + if ($value === null || is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float) $value; + } + + if (is_string($value)) { + $float = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + + if ($float !== null) { + return $float; + } + } + + throw $this->invalidType($key, 'float'); + } + + /** + * Returns an input field as a boolean. + * + * Supports dot-array syntax for nested input data. + */ + public function boolean(string $key, ?bool $default = null): ?bool + { + $value = $this->get($key, $default); + + if ($value === null || is_bool($value)) { + return $value; + } + + if (is_int($value) || is_string($value)) { + $boolean = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($boolean !== null) { + return $boolean; + } + } + + throw $this->invalidType($key, 'boolean'); + } + + /** + * Returns an input field as an array. + * + * Supports dot-array syntax for nested input data. + * + * @param array|null $default + * + * @return array|null + */ + public function array(string $key, ?array $default = null): ?array + { + $value = $this->get($key, $default); + + if ($value === null || is_array($value)) { + return $value; + } + + throw $this->invalidType($key, 'array'); + } + + protected function invalidType(string $key, string $type): InvalidArgumentException + { + return new InvalidArgumentException( + sprintf('The input "%s" value cannot be read as %s.', $key, $type), + ); + } +} diff --git a/system/Validation/ValidatedInput.php b/system/Validation/ValidatedInput.php new file mode 100644 index 000000000000..025833457458 --- /dev/null +++ b/system/Validation/ValidatedInput.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Validation; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Input\InputData; +use DateTimeZone; +use Exception; +use ReflectionEnum; +use UnitEnum; + +/** + * @see \CodeIgniter\Validation\ValidatedInputTest + */ +class ValidatedInput extends InputData +{ + /** + * Returns a validated field as a Time instance. + * + * Supports dot-array syntax for nested validated data. + */ + public function date( + string $key, + ?string $format = null, + DateTimeZone|string|null $timezone = null, + ): ?Time { + $value = $this->get($key); + + if ($value === null) { + return null; + } + + if (! is_string($value) || $value === '') { + throw $this->invalidType($key, 'date'); + } + + try { + if ($format === null) { + return Time::parse($value, $timezone); + } + + return Time::createFromFormat($format, $value, $timezone); + } catch (Exception) { + throw $this->invalidType($key, 'date'); + } + } + + /** + * Returns a validated field as an enum instance. + * + * Supports dot-array syntax for nested validated data. + * + * @template TEnum of UnitEnum + * + * @param class-string $enumClass + * @param TEnum|null $default + * + * @return TEnum|null + */ + public function enum(string $key, string $enumClass, ?UnitEnum $default = null): ?UnitEnum + { + if (! enum_exists($enumClass)) { + throw new InvalidArgumentException('The "' . $enumClass . '" class is not a valid enum.'); + } + + if ($default instanceof UnitEnum && ! $default instanceof $enumClass) { + throw $this->invalidType($key, $enumClass); + } + + $value = $this->get($key, $default); + + if ($value === null) { + return null; + } + + if ($value instanceof UnitEnum) { + if ($value instanceof $enumClass) { + return $value; + } + + throw $this->invalidType($key, $enumClass); + } + + $reflection = new ReflectionEnum($enumClass); + + if ($reflection->isBacked()) { + return $this->backedEnum($key, $enumClass, $reflection, $value); + } + + if (is_string($value)) { + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + } + + throw $this->invalidType($key, $enumClass); + } + + private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum + { + $backingType = $reflection->getBackingType()?->getName(); + + if ($backingType === 'int') { + if (is_string($value)) { + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + + if (! is_int($value)) { + throw $this->invalidType($key, $enumClass); + } + } elseif (! is_int($value) && ! is_string($value)) { + throw $this->invalidType($key, $enumClass); + } + + if ($backingType === 'string') { + $value = (string) $value; + } + + $enum = $enumClass::tryFrom($value); + + if ($enum === null) { + throw $this->invalidType($key, $enumClass); + } + + return $enum; + } + + protected function invalidType(string $key, string $type): InvalidArgumentException + { + return new InvalidArgumentException( + sprintf('The validated "%s" value cannot be read as %s.', $key, $type), + ); + } +} diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 52325bf2b6ef..8cb900c9b6a5 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -271,6 +271,14 @@ public function getValidated(): array return $this->validated; } + /** + * Returns the actual validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput + { + return service('validatedinput', $this->validated, false); + } + /** * Runs all of $rules against $field, until one fails, or * all of them have been processed. If one fails, it adds diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index 997bfb2bc0a4..1bf5c61a707e 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -162,4 +162,9 @@ public function showError(string $field, string $template = 'single'): string; * Returns the actual validated data. */ public function getValidated(): array; + + /** + * Returns the actual validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput; } diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 9790a217501f..e954a2f2fc4e 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -31,6 +31,7 @@ use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\ImageHandlerInterface; +use CodeIgniter\Input\InputData; use CodeIgniter\Language\Language; use CodeIgniter\Lock\LockManager; use CodeIgniter\Pager\Pager; @@ -44,6 +45,7 @@ use CodeIgniter\Test\Mock\MockSecurity; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidatedInput; use CodeIgniter\Validation\Validation; use CodeIgniter\View\Cell; use CodeIgniter\View\Parser; @@ -275,6 +277,24 @@ public function testNewValidation(): void $this->assertInstanceOf(Validation::class, $actual); } + public function testNewInputData(): void + { + $actual = Services::inputdata(['page' => '2']); + + $this->assertInstanceOf(InputData::class, $actual); + $this->assertSame(2, $actual->integer('page')); + $this->assertNotSame($actual, Services::inputdata(['page' => '2'])); + } + + public function testNewValidatedInput(): void + { + $actual = Services::validatedinput(['page' => '2']); + + $this->assertInstanceOf(ValidatedInput::class, $actual); + $this->assertSame(2, $actual->integer('page')); + $this->assertNotSame($actual, Services::validatedinput(['page' => '2'])); + } + public function testNewViewcellFromShared(): void { $actual = Services::viewcell(); diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index 768ef9898fe0..ee6af1a28e19 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; +use CodeIgniter\Validation\ValidatedInput; use Config\App; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; @@ -298,6 +299,20 @@ public function rules(): array $this->assertTrue($formRequest->hasValidated('note')); } + public function testValidatedInputReturnsValidatedInputObject(): void + { + service('superglobals')->setPost('title', 'Hello World'); + service('superglobals')->setPost('body', 'Some body text'); + + $formRequest = new ValidPostFormRequest($this->makeRequest()); + $formRequest->resolveRequest(); + + $input = $formRequest->validatedInput(); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('Hello World', $input->get('title')); + } + // ------------------------------------------------------------------------- // prepareForValidation hook // ------------------------------------------------------------------------- diff --git a/tests/system/Input/InputDataTest.php b/tests/system/Input/InputDataTest.php new file mode 100644 index 000000000000..e9cf78723e78 --- /dev/null +++ b/tests/system/Input/InputDataTest.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class InputDataTest extends CIUnitTestCase +{ + public function testGetReturnsInputFieldValue(): void + { + $input = new InputData(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->get('title')); + } + + public function testGetReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame('fallback', $input->get('title', 'fallback')); + } + + public function testHasReturnsTrueForNullInputField(): void + { + $input = new InputData(['note' => null]); + + $this->assertTrue($input->has('note')); + $this->assertNull($input->get('note', 'fallback')); + } + + public function testGetAndHasSupportDotSyntax(): void + { + $input = new InputData([ + 'post' => [ + 'meta' => [ + 'slug' => 'hello-world', + ], + ], + ]); + + $this->assertSame('hello-world', $input->get('post.meta.slug')); + $this->assertTrue($input->has('post.meta.slug')); + } + + public function testStringReturnsInputString(): void + { + $input = new InputData(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->string('title')); + } + + public function testStringReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame('Untitled', $input->string('title', 'Untitled')); + } + + public function testStringThrowsForInvalidInputValue(): void + { + $input = new InputData(['title' => 123]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "title" value cannot be read as string.'); + + $input->string('title'); + } + + public function testIntegerReturnsInputInteger(): void + { + $input = new InputData(['page' => '15']); + + $this->assertSame(15, $input->integer('page')); + } + + public function testIntegerReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame(1, $input->integer('page', 1)); + } + + public function testIntegerSupportsDotSyntax(): void + { + $input = new InputData(['filters' => ['page' => '2']]); + + $this->assertSame(2, $input->integer('filters.page')); + } + + public function testIntegerThrowsForInvalidInputValue(): void + { + $input = new InputData(['page' => '1.5']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "page" value cannot be read as integer.'); + + $input->integer('page'); + } + + public function testFloatReturnsInputFloat(): void + { + $input = new InputData(['price' => '15.50']); + + $this->assertEqualsWithDelta(15.50, $input->float('price'), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsInputIntegerAsFloat(): void + { + $input = new InputData(['price' => 15]); + + $this->assertEqualsWithDelta(15.0, $input->float('price'), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertEqualsWithDelta(1.5, $input->float('price', 1.5), PHP_FLOAT_EPSILON); + } + + public function testFloatThrowsForInvalidInputValue(): void + { + $input = new InputData(['price' => 'free']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "price" value cannot be read as float.'); + + $input->float('price'); + } + + public function testBooleanReturnsInputBoolean(): void + { + $input = new InputData(['active' => 'true']); + + $this->assertTrue($input->boolean('active')); + } + + public function testBooleanReturnsFalseForInputFalseString(): void + { + $input = new InputData(['active' => 'false']); + + $this->assertFalse($input->boolean('active')); + } + + public function testBooleanReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertFalse($input->boolean('active', false)); + } + + public function testBooleanThrowsForInvalidInputValue(): void + { + $input = new InputData(['active' => 'sometimes']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "active" value cannot be read as boolean.'); + + $input->boolean('active'); + } + + public function testArrayReturnsInputArray(): void + { + $input = new InputData(['tags' => ['php', 'ci']]); + + $this->assertSame(['php', 'ci'], $input->array('tags')); + } + + public function testArrayReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame(['draft'], $input->array('tags', ['draft'])); + } + + public function testArrayThrowsForInvalidInputValue(): void + { + $input = new InputData(['tags' => 'php']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "tags" value cannot be read as array.'); + + $input->array('tags'); + } + + public function testTypedAccessorsReturnNullForNullInputFields(): void + { + $input = new InputData([ + 'title' => null, + 'page' => null, + 'price' => null, + 'active' => null, + 'tags' => null, + ]); + + $this->assertNull($input->string('title', 'Untitled')); + $this->assertNull($input->integer('page', 1)); + $this->assertNull($input->float('price', 1.5)); + $this->assertNull($input->boolean('active', false)); + $this->assertNull($input->array('tags', ['draft'])); + } +} diff --git a/tests/system/Validation/ValidatedInputTest.php b/tests/system/Validation/ValidatedInputTest.php new file mode 100644 index 000000000000..e40c4e665a19 --- /dev/null +++ b/tests/system/Validation/ValidatedInputTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Validation; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Input\InputData; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; + +/** + * @internal + */ +#[Group('Others')] +final class ValidatedInputTest extends CIUnitTestCase +{ + public function testValidatedInputExtendsInputData(): void + { + $input = new ValidatedInput(['page' => '15']); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(15, $input->integer('page')); + } + + public function testDateReturnsValidatedTime(): void + { + $input = new ValidatedInput(['published_at' => '2026-05-04']); + + $this->assertInstanceOf(Time::class, $input->date('published_at')); + $this->assertSame('2026-05-04', $input->date('published_at')->toDateString()); + } + + public function testDateSupportsCustomFormat(): void + { + $input = new ValidatedInput(['published_at' => '04/05/2026']); + + $this->assertSame('2026-05-04', $input->date('published_at', 'd/m/Y')->toDateString()); + } + + public function testDateThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['published_at' => '']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "published_at" value cannot be read as date.'); + + $input->date('published_at'); + } + + public function testEnumReturnsValidatedStringBackedEnum(): void + { + $input = new ValidatedInput(['status' => 'active']); + + $this->assertSame(StatusEnum::ACTIVE, $input->enum('status', StatusEnum::class)); + } + + public function testEnumReturnsValidatedIntBackedEnum(): void + { + $input = new ValidatedInput(['role' => '2']); + + $this->assertSame(RoleEnum::ADMIN, $input->enum('role', RoleEnum::class)); + } + + public function testEnumReturnsValidatedUnitEnum(): void + { + $input = new ValidatedInput(['color' => 'GREEN']); + + $this->assertSame(ColorEnum::GREEN, $input->enum('color', ColorEnum::class)); + } + + public function testEnumReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + + $this->assertSame(StatusEnum::PENDING, $input->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } + + public function testEnumThrowsForDefaultFromDifferentEnumClass(): void + { + $input = new ValidatedInput([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $input->enum('status', StatusEnum::class, ColorEnum::GREEN); + } + + public function testEnumThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['status' => 'archived']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $input->enum('status', StatusEnum::class); + } + + public function testTypedAccessorsReturnNullForNullValidatedFields(): void + { + $input = new ValidatedInput([ + 'published_at' => null, + 'status' => null, + ]); + + $this->assertNotInstanceOf(Time::class, $input->date('published_at')); + $this->assertNotInstanceOf(StatusEnum::class, $input->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } +} diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index e2724bd6ca18..0b7c7eaef766 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -253,6 +253,17 @@ public function testRunReturnsFalseWithNothingToDo(): void $this->assertSame([], $this->validation->getValidated()); } + public function testGetValidatedInputReturnsValidatedInputObject(): void + { + $this->validation->setRules(['role' => 'required']); + $this->assertTrue($this->validation->run(['role' => 'administrator'])); + + $input = $this->validation->getValidatedInput(); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('administrator', $input->get('role')); + } + public function testRuleClassesInstantiatedOnce(): void { $this->validation->setRules([]); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index ca51e62004e9..477fe8ff3f11 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -46,6 +46,7 @@ update your implementations to include the new methods or method changes to ensu - **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, and ``transaction()`` methods. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. +- **Validation:** ``CodeIgniter\Validation\ValidationInterface`` now requires the ``getValidatedInput()`` method, which returns a ``CodeIgniter\Validation\ValidatedInput`` instance. Method Signature Changes ======================== @@ -239,7 +240,7 @@ Helpers and Functions HTTP ==== -- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, and authorization logic for a single HTTP request. +- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, authorization logic and typed access to validated input for a single HTTP request. - Added ``SSEResponse`` class for streaming Server-Sent Events (SSE) over HTTP. See :ref:`server-sent-events`. - ``Response`` and its child classes no longer require ``Config\App`` passed to their constructors. Consequently, ``CURLRequest``'s ``$config`` parameter is unused and will be removed in a future release. @@ -261,11 +262,13 @@ Validation ========== - Custom rule methods that set an error via the ``&$error`` reference parameter now support the ``{field}``, ``{param}``, and ``{value}`` placeholders, consistent with language-file and ``setRule()``/``setRules()`` error messages. +- Added ``Validation::getValidatedInput()`` to access validated data through a ``ValidatedInput`` object. Others ====== - **Float and Double Casting:** Added support for precision and rounding mode when casting to float or double in entities. +- Added ``CodeIgniter\Input\InputData`` as a reusable typed input data object. - Float and Double casting now throws ``CastException::forInvalidFloatRoundingMode()`` if an rounding mode other than up, down, even or odd is provided. - **Environment:** Added ``CodeIgniter\EnvironmentDetector`` class and corresponding ``environment`` service as a mockable wrapper around the ``ENVIRONMENT`` constant. Framework internals that previously compared ``ENVIRONMENT`` directly now go through this service, making environment-specific branches reachable in tests via ``Services::injectMock()``. See :ref:`environment-detector-service`. diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index 81f44add9d01..79aa1522bfde 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -63,6 +63,23 @@ to check whether a validated key exists, including keys whose value is .. literalinclude:: form_requests/014.php :lines: 2- +Typed Validated Input +===================== + +``validatedInput()`` returns the same validated data as a typed input object. +This keeps the array-based APIs unchanged while making common controller values +easier to read after validation has succeeded. + +After the FormRequest has been validated, read the successful values in the +controller: + +.. literalinclude:: form_requests/015.php + :lines: 2- + +These typed methods do not replace validation rules. They only make accepted +values easier to consume in the controller. See :ref:`validation-validated-input` +for the full behavior of the typed input methods. + Accessing Other Request Data ============================ @@ -229,7 +246,7 @@ whose type extends ``FormRequest``: #. ``run()`` executes the validation rules. If it fails, ``failedValidation()`` is called, and its response is returned to the client. #. The validated data is stored internally and available via ``validated()``, - ``getValidated()``, and ``hasValidated()``. + ``validatedInput()``, ``getValidated()``, and ``hasValidated()``. #. The resolved FormRequest object is injected into the controller method or closure. diff --git a/user_guide_src/source/incoming/form_requests/015.php b/user_guide_src/source/incoming/form_requests/015.php new file mode 100644 index 000000000000..12d76e1b8d46 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/015.php @@ -0,0 +1,11 @@ +validatedInput(); + +$page = $input->integer('page', 1); +$rating = $input->float('rating', 0.0); +$active = $input->boolean('active', false); +$publishedAt = $input->date('published_at', 'Y-m-d'); +$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT); diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 13f7f88c51df..2300650cfdd9 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -478,6 +478,72 @@ the validation rules. .. literalinclude:: validation/045.php :lines: 2- +.. _validation-validated-input: + +Typed Validated Input +--------------------- + +``getValidatedInput()`` returns the same validated data as a +``CodeIgniter\Validation\ValidatedInput`` object. Use it after validation +succeeds when you want to read common controller values as strings, integers, +floats, booleans, arrays, dates, or enums: + +.. versionadded:: 4.8.0 + +.. literalinclude:: validation/048.php + :lines: 2- + +Validation decides whether input is acceptable. The typed input object only +helps you consume values that already passed validation. If a present value +cannot be read as the requested type, an ``InvalidArgumentException`` is thrown. +This usually means the validation rules and the expected type do not match. + +``ValidatedInput`` extends ``CodeIgniter\Input\InputData``. This keeps generic +typed input access reusable while adding validation-specific readers for dates +and enums. + +The typed input object has the following methods: + +All methods support dot-array syntax for nested validated data. + +* ``get($key, $default = null)`` returns the raw validated value. If the field + is missing, it returns the default value. +* ``has($key)`` returns whether the field exists in the validated data, even if + its value is ``null``. +* ``string($key, $default = null)`` returns ``string|null``. If the field is + missing, it returns the default value or ``null``. +* ``integer($key, $default = null)`` returns ``int|null``. If the field is + missing, it returns the default value or ``null``. +* ``float($key, $default = null)`` returns ``float|null``. If the field is + missing, it returns the default value or ``null``. +* ``boolean($key, $default = null)`` returns ``bool|null``. If the field is + missing, it returns the default value or ``null``. +* ``array($key, $default = null)`` returns ``array|null``. If the field is + missing, it returns the default value or ``null``. +* ``date($key, $format = null, $timezone = null)`` returns + :php:class:`CodeIgniter\\I18n\\Time` or ``null``. Pass a format when the value + should be parsed with a specific date format. +* ``enum($key, $enumClass, $default = null)`` returns an enum instance or + ``null``. The default value must be ``null`` or an instance of the requested + enum class. + +Fields that are present with a ``null`` value return ``null``. This lets you +distinguish a missing optional field from a field that was validated as +``null``. + +Use validation rules such as ``integer``, ``decimal``, ``valid_date``, +``in_list``, or a custom rule to ensure the value matches the type you plan to read. The +``date()`` method only parses the value; validation rules should enforce +acceptable date formats and ranges. For strict calendar validation, add a rule +such as ``valid_date[Y-m-d]``. + +The ``enum()`` method accepts PHP enum class names. Backed enums are matched by +their backing value, while unit enums are matched by case name. + +The ``boolean()`` method uses PHP's boolean validation behavior, so common form +values like ``"1"``, ``"0"``, ``"true"``, ``"false"``, ``"yes"``, ``"no"``, +``"on"``, and ``"off"`` are accepted. + .. _saving-validation-rules-to-config-file: Saving Sets of Validation Rules to the Config File diff --git a/user_guide_src/source/libraries/validation/048.php b/user_guide_src/source/libraries/validation/048.php new file mode 100644 index 000000000000..17dbbf9b30f9 --- /dev/null +++ b/user_guide_src/source/libraries/validation/048.php @@ -0,0 +1,39 @@ +setRules([ + 'title' => 'required|string', + 'page' => 'permit_empty|integer', + 'rating' => 'permit_empty|decimal', + 'active' => 'permit_empty|in_list[0,1,true,false,yes,no,on,off]', + 'tags' => 'permit_empty|is_array', + 'published_at' => 'permit_empty|valid_date[Y-m-d]', + 'status' => 'permit_empty|in_list[draft,published]', +]); + +$data = [ + 'title' => 'Hello World', + 'page' => '2', + 'rating' => '4.5', + 'active' => 'true', + 'tags' => ['php', 'codeigniter'], + 'published_at' => '2026-05-04', + 'status' => 'published', +]; + +if (! $validation->run($data)) { + // The validation failed. + return; +} + +$input = $validation->getValidatedInput(); + +$title = $input->string('title'); +$page = $input->integer('page', 1); +$rating = $input->float('rating', 0.0); +$active = $input->boolean('active', false); +$tags = $input->array('tags', []); +$publishedAt = $input->date('published_at', 'Y-m-d'); +$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT);