diff --git a/deptrac.yaml b/deptrac.yaml index 8b5f7dc611e9..53c6ff7a58a9 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -210,6 +210,7 @@ deptrac: - Cookie - Files - I18n + - Input - Security - URI Images: diff --git a/structarmed.php b/structarmed.php index 276886d219e4..28c74e556b9e 100644 --- a/structarmed.php +++ b/structarmed.php @@ -100,7 +100,7 @@ 'Files' => ['I18n'], 'Filters' => ['HTTP'], 'Honeypot' => ['Filters', 'HTTP'], - 'HTTP' => ['Cookie', 'Files', 'I18n', 'Security', 'URI'], + 'HTTP' => ['Cookie', 'Files', 'I18n', 'Input', 'Security', 'URI'], 'Images' => ['Files', 'I18n'], 'Lock' => ['Cache'], 'Model' => ['Database', 'DataCaster', 'DataConverter', 'Entity', 'I18n', 'Pager', 'Validation'], diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index f9a57a4098ba..13b8f56e0b6c 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -569,6 +569,14 @@ public function getGet($index = null, $filter = null, $flags = null) return $this->fetchGlobal('get', $index, $filter, $flags); } + /** + * Returns a typed input data selector. + */ + public function input(): RequestInput + { + return new RequestInput($this, service('inputdatafactory')); + } + /** * Fetch an item from POST. * diff --git a/system/HTTP/RequestInput.php b/system/HTTP/RequestInput.php new file mode 100644 index 000000000000..4c94949cbe4f --- /dev/null +++ b/system/HTTP/RequestInput.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Input\InputData; +use CodeIgniter\Input\InputDataFactory; + +/** + * Provides typed input access for request data sources. + * + * @see \CodeIgniter\HTTP\RequestInputTest + */ +final readonly class RequestInput +{ + public function __construct( + private IncomingRequest $request, + private InputDataFactory $factory, + ) { + } + + /** + * Returns GET parameters as a typed input object. + */ + public function get(): InputData + { + $data = $this->request->getGet(); + + return $this->factory->create(is_array($data) ? $data : []); + } + + /** + * Returns POST body parameters as a typed input object. + */ + public function post(): InputData + { + $data = $this->request->getPost(); + + return $this->factory->create(is_array($data) ? $data : []); + } + + /** + * Returns JSON body parameters as a typed input object. + */ + public function json(): InputData + { + $data = $this->request->getJSON(true) ?? []; + + if (! is_array($data)) { + throw HTTPException::forUnsupportedJSONFormat(); + } + + return $this->factory->create($data); + } + + /** + * Returns raw input parameters as a typed input object. + */ + public function raw(): InputData + { + return $this->factory->create($this->request->getRawInput()); + } +} diff --git a/tests/system/HTTP/RequestInputTest.php b/tests/system/HTTP/RequestInputTest.php new file mode 100644 index 000000000000..b12fdf646c70 --- /dev/null +++ b/tests/system/HTTP/RequestInputTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Input\InputData; +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[BackupGlobals(true)] +#[Group('SeparateProcess')] +final class RequestInputTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Services::injectMock('superglobals', new Superglobals([], [], [], [], [], [])); + } + + private function createRequest(?App $config = null, ?string $body = null): IncomingRequest + { + $config ??= new App(); + + return new IncomingRequest( + $config, + new SiteURI($config, ''), + $body, + new UserAgent(), + ); + } + + public function testInputReturnsRequestInput(): void + { + $request = $this->createRequest(); + $input = $request->input(); + + $this->assertInstanceOf(RequestInput::class, $input); + } + + public function testGetReadsGetData(): void + { + service('superglobals')->setGet('page', '3'); + service('superglobals')->setGet('filters', ['active' => 'true']); + service('superglobals')->setPost('page', '10'); + + $input = $this->createRequest()->input()->get(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(3, $input->integer('page')); + $this->assertTrue($input->boolean('filters.active')); + $this->assertSame(1, $input->integer('missing', 1)); + } + + public function testPostReadsPostData(): void + { + service('superglobals')->setGet('remember', '0'); + service('superglobals')->setPost('remember', '1'); + service('superglobals')->setPost('tags', ['php', 'ci4']); + + $input = $this->createRequest()->input()->post(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertTrue($input->boolean('remember')); + $this->assertSame(['php', 'ci4'], $input->array('tags')); + } + + public function testJsonReadsJsonBody(): void + { + $json = json_encode([ + 'page' => '4', + 'filters' => ['active' => 'true'], + 'nullable' => null, + ]); + + $input = $this->createRequest(new App(), $json)->input()->json(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(4, $input->integer('page')); + $this->assertTrue($input->boolean('filters.active')); + $this->assertTrue($input->has('nullable')); + } + + public function testJsonReturnsEmptyInputForEmptyJsonBody(): void + { + $input = $this->createRequest(new App())->input()->json(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertFalse($input->has('name')); + } + + public function testJsonRejectsScalarJsonBody(): void + { + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('The provided JSON format is not supported.'); + + $this->createRequest(new App(), '"hello"')->input()->json(); + } + + public function testJsonKeepsInvalidJsonError(): void + { + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('Failed to parse JSON string. Error: Syntax error'); + + $this->createRequest(new App(), 'Invalid JSON string')->input()->json(); + } + + public function testRawReadsRawInputData(): void + { + $input = $this->createRequest(new App(), 'title=Hello&published=1')->input()->raw(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame('Hello', $input->string('title')); + $this->assertTrue($input->boolean('published')); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 7670598cde1f..cacc32c532d7 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -261,6 +261,7 @@ HTTP - Added the ``retry`` option to ``CURLRequest`` for retrying failed responses with configurable delays, retryable status codes, optional transient cURL error retries, and ``Retry-After`` support. See :ref:`curlrequest-request-options-retry`. - 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 ``IncomingRequest::input()`` to read GET, POST, JSON, and raw request data through ``InputData``. - 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. diff --git a/user_guide_src/source/incoming/incomingrequest.rst b/user_guide_src/source/incoming/incomingrequest.rst index a96353dadfba..8e30fabb425f 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -161,6 +161,30 @@ The ``getVar()`` method will pull from ``$_REQUEST``, so will return any data fr .. note:: If the incoming request has a ``Content-Type`` header set to ``application/json``, the ``getVar()`` method returns the JSON data instead of ``$_REQUEST`` data. +.. _incomingrequest-typed-request-input: + +Typed Request Input +=================== + +.. versionadded:: 4.8.0 + +``input()`` returns a ``CodeIgniter\HTTP\RequestInput`` object. Use it to read +values from a specific part of the request with typed fallback helpers: + +.. literalinclude:: incomingrequest/046.php + :lines: 2- + +``input()->get()`` reads query-string parameters. ``input()->post()`` reads +POST body parameters. ``input()->json()`` reads JSON request body parameters. +``input()->raw()`` reads raw input parameters, like ``getRawInput()``. + +These methods keep GET, POST, JSON, and raw data separate. They do not combine +multiple request sources for you. + +These methods do not validate input. They are fallback-friendly helpers for +reading raw request data. Use Validation or :ref:`form-requests` when input +must satisfy application rules before it is consumed. + .. _incomingrequest-getting-json-data: Getting JSON Data @@ -406,6 +430,11 @@ The methods provided by the parent classes that are available are: .. literalinclude:: incomingrequest/045.php + .. php:method:: input() + + :returns: A typed input data selector. + :rtype: CodeIgniter\\HTTP\\RequestInput + .. php:method:: getPost([$index = null[, $filter = null[, $flags = null]]]) :param string $index: The name of the variable/key to look for. @@ -519,4 +548,3 @@ The methods provided by the parent classes that are available are: .. note:: Prior to v4.4.0, this was the safest method to determine the "current URI", since ``IncomingRequest::$uri`` might not be aware of the complete App configuration for base URLs. - diff --git a/user_guide_src/source/incoming/incomingrequest/046.php b/user_guide_src/source/incoming/incomingrequest/046.php new file mode 100644 index 000000000000..d0c6aca18158 --- /dev/null +++ b/user_guide_src/source/incoming/incomingrequest/046.php @@ -0,0 +1,6 @@ +input()->get()->integer('page', 1); +$remember = $request->input()->post()->boolean('remember', false); +$name = $request->input()->json()->string('name'); +$published = $request->input()->raw()->boolean('published', false);