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/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index f9a57a4098ba..e6fc82a8cf41 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -17,6 +17,7 @@ use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\Files\FileCollection; use CodeIgniter\HTTP\Files\UploadedFile; +use CodeIgniter\Input\InputData; use Config\App; use Config\Services; use Locale; @@ -555,6 +556,59 @@ public function getRawInputVar($index = null, ?int $filter = null, $flags = null return $output; } + /** + * Returns query-string parameters as a typed input object. + */ + public function getQueryInput(): InputData + { + $data = $this->getGet(); + + return service('inputdatafactory')->create(is_array($data) ? $data : []); + } + + /** + * Returns POST body parameters as a typed input object. + */ + public function getPostInput(): InputData + { + $data = $this->getPost(); + + return service('inputdatafactory')->create(is_array($data) ? $data : []); + } + + /** + * Returns request body payload parameters as a typed input object. + */ + public function getPayloadInput(): InputData + { + $contentType = $this->getHeaderLine('Content-Type'); + + if (str_contains($contentType, 'application/json')) { + $data = $this->getJSON(true) ?? []; + + if (! is_array($data)) { + throw HTTPException::forUnsupportedJSONFormat(); + } + + return service('inputdatafactory')->create($data); + } + + if ( + in_array($this->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true) + && ! str_contains($contentType, 'multipart/form-data') + ) { + return service('inputdatafactory')->create($this->getRawInput()); + } + + if (in_array($this->getMethod(), [Method::GET, Method::HEAD], true)) { + return service('inputdatafactory')->create([]); + } + + $data = $this->getPost(); + + return service('inputdatafactory')->create(is_array($data) ? $data : []); + } + /** * Fetch an item from GET data. * diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index aec26069fdcf..adb708411bba 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\Files\UploadedFile; +use CodeIgniter\Input\InputData; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -86,6 +87,35 @@ public function testCanGrabPostVars(): void $this->assertNull($this->request->getPost('TESTY')); } + public function testGetQueryInputReadsQueryData(): void + { + service('superglobals')->setGet('page', '3'); + service('superglobals')->setGet('filters', ['active' => 'true']); + service('superglobals')->setPost('page', '10'); + + $request = $this->createRequest(); + $input = $request->getQueryInput(); + + $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 testGetPostInputReadsPostData(): void + { + service('superglobals')->setGet('remember', '0'); + service('superglobals')->setPost('remember', '1'); + service('superglobals')->setPost('tags', ['php', 'ci4']); + + $request = $this->createRequest(); + $input = $request->getPostInput(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertTrue($input->boolean('remember')); + $this->assertSame(['php', 'ci4'], $input->array('tags')); + } + public function testCanGrabPostBeforeGet(): void { service('superglobals')->setPost('TEST', '5'); @@ -572,6 +602,104 @@ public function testCanGrabGetRawInput(): void $this->assertSame($expected, $request->getRawInput()); } + public function testGetPayloadInputReadsJsonBody(): void + { + $json = json_encode([ + 'page' => '4', + 'filters' => ['active' => 'true'], + 'nullable' => null, + ]); + + $request = $this->createRequest(new App(), $json); + $request->setHeader('Content-Type', 'application/json'); + + $input = $request->getPayloadInput(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertSame(4, $input->integer('page')); + $this->assertTrue($input->boolean('filters.active')); + $this->assertTrue($input->has('nullable')); + } + + #[DataProvider('provideGetPayloadInputReadsRawBodyForWriteRequests')] + public function testGetPayloadInputReadsRawBodyForWriteRequests(string $method): void + { + $request = $this->createRequest(new App(), 'title=Hello&published=1') + ->withMethod($method); + + $input = $request->getPayloadInput(); + + $this->assertSame('Hello', $input->string('title')); + $this->assertTrue($input->boolean('published')); + } + + /** + * @return iterable + */ + public static function provideGetPayloadInputReadsRawBodyForWriteRequests(): iterable + { + yield 'PUT' => ['PUT']; + + yield 'PATCH' => ['PATCH']; + + yield 'DELETE' => ['DELETE']; + } + + public function testGetPayloadInputReadsPostBodyForPostRequests(): void + { + service('superglobals')->setGet('title', 'Query title'); + service('superglobals')->setPost('title', 'Post title'); + + $request = $this->createRequest()->withMethod('POST'); + $input = $request->getPayloadInput(); + + $this->assertSame('Post title', $input->string('title')); + } + + public function testGetPayloadInputDoesNotReadQueryDataForGetRequests(): void + { + service('superglobals')->setGet('page', '2'); + + $request = $this->createRequest()->withMethod('GET'); + $input = $request->getPayloadInput(); + + $this->assertFalse($input->has('page')); + $this->assertSame(1, $input->integer('page', 1)); + } + + public function testGetPayloadInputReturnsEmptyInputForEmptyJsonBody(): void + { + $request = $this->createRequest(new App()); + $request->setHeader('Content-Type', 'application/json'); + + $input = $request->getPayloadInput(); + + $this->assertInstanceOf(InputData::class, $input); + $this->assertFalse($input->has('name')); + } + + public function testGetPayloadInputRejectsScalarJsonBody(): void + { + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('The provided JSON format is not supported.'); + + $request = $this->createRequest(new App(), '"hello"'); + $request->setHeader('Content-Type', 'application/json'); + + $request->getPayloadInput(); + } + + public function testGetPayloadInputKeepsInvalidJsonError(): void + { + $this->expectException(HTTPException::class); + $this->expectExceptionMessage('Failed to parse JSON string. Error: Syntax error'); + + $request = $this->createRequest(new App(), 'Invalid JSON string'); + $request->setHeader('Content-Type', 'application/json'); + + $request->getPayloadInput(); + } + /** * @param string $rawstring * @param mixed $var diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 7670598cde1f..3340c3b99777 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::getQueryInput()``, ``getPostInput()``, and ``getPayloadInput()`` to read source-specific 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..5f72eb417d88 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -161,6 +161,32 @@ 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-source-input: + +Typed Source Input +================== + +.. versionadded:: 4.8.0 + +``getQueryInput()``, ``getPostInput()``, and ``getPayloadInput()`` return +request data as a ``CodeIgniter\Input\InputData`` object. Use these methods +when you want source-explicit access with typed fallback helpers: + +.. literalinclude:: incomingrequest/046.php + :lines: 2- + +``getQueryInput()`` reads query-string parameters. ``getPostInput()`` reads +POST body parameters. ``getPayloadInput()`` reads the request body payload: +JSON requests use the decoded JSON body, ``PUT``, ``PATCH``, and ``DELETE`` +requests use ``getRawInput()`` when they are not multipart requests, and +ordinary form requests use POST body parameters. +For non-JSON ``GET`` and ``HEAD`` requests, use ``getQueryInput()``; +``getPayloadInput()`` returns an empty input object. + +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 +432,11 @@ The methods provided by the parent classes that are available are: .. literalinclude:: incomingrequest/045.php + .. php:method:: getQueryInput() + + :returns: Query-string parameters as a typed input object. + :rtype: CodeIgniter\\Input\\InputData + .. php:method:: getPost([$index = null[, $filter = null[, $flags = null]]]) :param string $index: The name of the variable/key to look for. @@ -418,6 +449,16 @@ The methods provided by the parent classes that are available are: This method is identical to ``getGet()``, only it fetches POST data. + .. php:method:: getPostInput() + + :returns: POST body parameters as a typed input object. + :rtype: CodeIgniter\\Input\\InputData + + .. php:method:: getPayloadInput() + + :returns: Request body payload parameters as a typed input object. + :rtype: CodeIgniter\\Input\\InputData + .. php:method:: getPostGet([$index = null[, $filter = null[, $flags = null]]]) :param string $index: The name of the variable/key to look for. @@ -519,4 +560,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..70ee5d516c6d --- /dev/null +++ b/user_guide_src/source/incoming/incomingrequest/046.php @@ -0,0 +1,5 @@ +getQueryInput()->integer('page', 1); +$remember = $request->getPostInput()->boolean('remember', false); +$name = $request->getPayloadInput()->string('name');