diff --git a/.gitignore b/.gitignore index ecc72d731..10ad356f5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules/ package-lock.json /.claude /.playwright-mcp +.phpunit.result.cache \ No newline at end of file diff --git a/src/Event/Http/Psr7Bridge.php b/src/Event/Http/Psr7Bridge.php index 1745fdd86..794b5f304 100644 --- a/src/Event/Http/Psr7Bridge.php +++ b/src/Event/Http/Psr7Bridge.php @@ -3,6 +3,7 @@ namespace Bref\Event\Http; use Bref\Context\Context; +use Bref\Support\MultipartArray; use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\Stream; use Nyholm\Psr7\UploadedFile; @@ -124,32 +125,17 @@ private static function parseBodyAndUploadedFiles(HttpRequestEvent $event): arra } file_put_contents($tmpPath, $part->getBody()); $file = new UploadedFile($tmpPath, filesize($tmpPath), UPLOAD_ERR_OK, $part->getFileName(), $part->getMimeType()); - self::parseKeyAndInsertValueInArray($files, $part->getName(), $file); + $files = MultipartArray::setValue($files, $part->getName(), $file); } else { if ($parsedBody === null) { $parsedBody = []; } - self::parseKeyAndInsertValueInArray($parsedBody, $part->getName(), $part->getBody()); + $parsedBody = MultipartArray::setValue($parsedBody, $part->getName(), $part->getBody()); } } return [$files, $parsedBody]; } - /** - * Parse a string key like "files[id_cards][jpg][]" and do $array['files']['id_cards']['jpg'][] = $value - */ - private static function parseKeyAndInsertValueInArray(array &$array, string $key, mixed $value): void - { - $parsed = []; - // We use parse_str to parse the key in the same way PHP does natively - // We use "=mock" because the value can be an object (in case of uploaded files) - parse_str(urlencode($key) . '=mock', $parsed); - // Replace `mock` with the actual value - array_walk_recursive($parsed, fn (&$v) => $v = $value); - // Merge recursively into the main array to avoid overwriting existing values - $array = array_merge_recursive($array, $parsed); - } - /** * Cleanup previously uploaded files. */ diff --git a/src/Support/MultipartArray.php b/src/Support/MultipartArray.php new file mode 100644 index 000000000..eb35cfa2a --- /dev/null +++ b/src/Support/MultipartArray.php @@ -0,0 +1,168 @@ + $array + * @return array + */ + public static function setValue(array $array, string $name, mixed $value): array + { + if (! str_contains($name, '[')) { + if (array_key_exists($name, $array) && ! is_array($array[$name])) { + $array[$name] = [$array[$name], $value]; + } else { + $array[$name] = $value; + } + + return $array; + } + + $existingValue = self::getValueAtPath($array, $name); + if ($existingValue !== null && ! is_array($existingValue)) { + return self::appendDuplicateFieldValue($array, $name, $value); + } + + return self::setMultipartArrayValue($array, $name, $value); + } + + /** + * @param array $array + * @return array + */ + private static function setMultipartArrayValue(array $array, string $name, mixed $value): array + { + if (self::hasMalformedSegment($name)) { + return self::setValueUsingParseStr($array, $name, $value); + } + + $segments = explode('[', $name); + + $pointer = &$array; + + foreach ($segments as $key => $segment) { + if ($key === 0) { + $pointer = &$pointer[$segment]; + + continue; + } + + $segment = substr($segment, 0, -1); + + if ($segment === '') { + $pointer = &$pointer[]; + } else { + $pointer = &$pointer[$segment]; + } + } + + $pointer = $value; + + return $array; + } + + private static function malformedMultipartSegment(string $segment): bool + { + return $segment === '' || substr($segment, -1) !== ']'; + } + + private static function hasMalformedSegment(string $name): bool + { + foreach (explode('[', $name) as $key => $segment) { + if ($key > 0 && self::malformedMultipartSegment($segment)) { + return true; + } + } + + return false; + } + + /** + * @param array $array + * @return array + */ + private static function setValueUsingParseStr(array $array, string $name, mixed $value): array + { + $parsed = []; + parse_str(urlencode($name) . '=__bref__', $parsed); + array_walk_recursive($parsed, fn (&$v) => $v = $value); + + return array_replace_recursive($array, $parsed); + } + + /** + * When the same field name is submitted more than once, append the new value + * to the parent list instead of overwriting the existing entry. + * + * @param array $array + * @return array + */ + private static function appendDuplicateFieldValue(array $array, string $name, mixed $value): array + { + $segments = explode('[', $name); + $parent = &$array[$segments[0]]; + + for ($i = 1; $i < count($segments) - 1; $i++) { + $segment = substr($segments[$i], 0, -1); + $parent = &$parent[$segment]; + } + + $parent[] = $value; + + return $array; + } + + /** + * @param array $array + */ + private static function getValueAtPath(array $array, string $name): mixed + { + if (! str_contains($name, '[')) { + return $array[$name] ?? null; + } + + $segments = explode('[', $name); + $pointer = $array; + + foreach ($segments as $key => $segment) { + if ($key === 0) { + if (! is_array($pointer) || ! array_key_exists($segment, $pointer)) { + return null; + } + $pointer = $pointer[$segment]; + + continue; + } + + if (self::malformedMultipartSegment($segment)) { + return null; + } + + $segment = substr($segment, 0, -1); + + if ($segment === '') { + if (! is_array($pointer)) { + return null; + } + $pointer = end($pointer); + if ($pointer === false) { + return null; + } + } else { + if (! is_array($pointer) || ! array_key_exists($segment, $pointer)) { + return null; + } + $pointer = $pointer[$segment]; + } + } + + return $pointer; + } +} diff --git a/tests/Event/Http/CommonHttpTest.php b/tests/Event/Http/CommonHttpTest.php index c3940b5fa..b0924586b 100644 --- a/tests/Event/Http/CommonHttpTest.php +++ b/tests/Event/Http/CommonHttpTest.php @@ -420,6 +420,40 @@ public function test POST request with multipart file uploads(int $version ); } + /** + * @dataProvider provide API Gateway versions + */ + public function test POST request with multipart form data containing structured arrays(int $version) + { + $this->fromFixture(__DIR__ . "/Fixture/ag-v$version-body-form-multipart-structured-arrays.json"); + + $this->assertContentType('multipart/form-data; boundary=testBoundary'); + $this->assertMethod('POST'); + $this->assertParsedBody([ + 'content' => '

Test content

', + 'some_id' => '3034', + 'references' => [ + [ + 'other_id' => '4390954279', + 'url' => '', + ], + [ + 'other_id' => '4313323164', + 'url' => '', + ], + [ + 'other_id' => '', + 'url' => 'https://someurl.com/node/745911', + ], + ], + 'tags' => [ + 'public health', + 'public finance', + ], + '_method' => 'PATCH', + ]); + } + /** * @dataProvider provide API Gateway versions */ diff --git a/tests/Event/Http/Fixture/ag-v1-body-form-multipart-structured-arrays.json b/tests/Event/Http/Fixture/ag-v1-body-form-multipart-structured-arrays.json new file mode 100644 index 000000000..60158768e --- /dev/null +++ b/tests/Event/Http/Fixture/ag-v1-body-form-multipart-structured-arrays.json @@ -0,0 +1,53 @@ +{ + "version": "1.0", + "resource": "/path", + "path": "/path", + "httpMethod": "POST", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Cache-Control": "no-cache", + "Content-Type": "multipart/form-data; boundary=testBoundary", + "Host": "example.org", + "User-Agent": "PostmanRuntime/7.20.1", + "X-Amzn-Trace-Id": "Root=1-ffffffff-ffffffffffffffffffffffff", + "X-Forwarded-For": "1.1.1.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "queryStringParameters": null, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "resourceId": "xxxxxx", + "resourcePath": "/path", + "httpMethod": "POST", + "extendedRequestId": "XXXXXX-xxxxxxxx=", + "requestTime": "24/Nov/2019:18:55:08 +0000", + "path": "/path", + "accountId": "123400000000", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "dev", + "requestTimeEpoch": 1574621708700, + "requestId": "ffffffff-ffff-4fff-ffff-ffffffffffff", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "sourceIp": "1.1.1.1", + "principalOrgId": null, + "accessKey": null, + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "PostmanRuntime/7.20.1", + "user": null + }, + "domainName": "example.org", + "apiId": "xxxxxxxxxx" + }, + "body": "--testBoundary\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\n

Test content

\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"some_id\"\r\n\r\n3034\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[0][other_id]\"\r\n\r\n4390954279\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[0][url]\"\r\n\r\n\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[1][other_id]\"\r\n\r\n4313323164\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[1][url]\"\r\n\r\n\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[2][other_id]\"\r\n\r\n\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[2][url]\"\r\n\r\nhttps://someurl.com/node/745911\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"tags[0]\"\r\n\r\npublic health\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"tags[1]\"\r\n\r\npublic finance\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"_method\"\r\n\r\nPATCH\r\n--testBoundary--\r\n", + "isBase64Encoded": false +} diff --git a/tests/Event/Http/Fixture/ag-v2-body-form-multipart-structured-arrays.json b/tests/Event/Http/Fixture/ag-v2-body-form-multipart-structured-arrays.json new file mode 100644 index 000000000..6adf2e606 --- /dev/null +++ b/tests/Event/Http/Fixture/ag-v2-body-form-multipart-structured-arrays.json @@ -0,0 +1,41 @@ +{ + "version": "2.0", + "routeKey": "ANY /path", + "rawPath": "/path", + "rawQueryString": "", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Cache-Control": "no-cache", + "Content-Type": "multipart/form-data; boundary=testBoundary", + "Host": "example.org", + "User-Agent": "PostmanRuntime/7.20.1", + "X-Amzn-Trace-Id": "Root=1-ffffffff-ffffffffffffffffffffffff", + "X-Forwarded-For": "1.1.1.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "queryStringParameters": null, + "stageVariables": null, + "requestContext": { + "accountId": "123400000000", + "apiId": "xxxxxxxxxx", + "domainName": "example.org", + "domainPrefix": "0000000000", + "http": { + "method": "POST", + "path": "/path", + "protocol": "HTTP/1.1", + "sourceIp": "1.1.1.1", + "userAgent": "PostmanRuntime/7.20.1" + }, + "requestId": "JTHoQgr2oAMEPMg=", + "routeId": "47matwk", + "routeKey": "ANY /path", + "stage": "$default", + "time": "24/Nov/2019:18:55:08 +0000", + "timeEpoch": 1574621708700 + }, + "body": "--testBoundary\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\n

Test content

\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"some_id\"\r\n\r\n3034\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[0][other_id]\"\r\n\r\n4390954279\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[0][url]\"\r\n\r\n\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[1][other_id]\"\r\n\r\n4313323164\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[1][url]\"\r\n\r\n\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[2][other_id]\"\r\n\r\n\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"references[2][url]\"\r\n\r\nhttps://someurl.com/node/745911\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"tags[0]\"\r\n\r\npublic health\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"tags[1]\"\r\n\r\npublic finance\r\n--testBoundary\r\nContent-Disposition: form-data; name=\"_method\"\r\n\r\nPATCH\r\n--testBoundary--\r\n", + "isBase64Encoded": false +} diff --git a/tests/Event/Http/Psr7BridgeTest.php b/tests/Event/Http/Psr7BridgeTest.php index a6d0f921c..df8e37db3 100644 --- a/tests/Event/Http/Psr7BridgeTest.php +++ b/tests/Event/Http/Psr7BridgeTest.php @@ -32,6 +32,220 @@ public function test I can create a response from a PSR7 response() ], $response->toApiGatewayFormat()); } + public function test I can convert a request from an event with complex multipart form data structures() + { + $body = "--complexBoundary\r\nContent-Disposition: form-data; name=\"simple_string\"\r\n\r\nHello World\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"empty_string\"\r\n\r\n\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"numeric_string\"\r\n\r\n12345\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"boolean_string\"\r\n\r\ntrue\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"indexed_array[0]\"\r\n\r\nfirst_item\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"indexed_array[1]\"\r\n\r\nsecond_item\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"indexed_array[2]\"\r\n\r\nthird_item\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"associative_array[name]\"\r\n\r\nJohn Doe\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"associative_array[age]\"\r\n\r\n30\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"associative_array[email]\"\r\n\r\njohn@example.com\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[user][profile][first_name]\"\r\n\r\nJohn\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[user][profile][last_name]\"\r\n\r\nDoe\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[user][profile][age]\"\r\n\r\n30\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[user][settings][theme]\"\r\n\r\ndark\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[user][settings][notifications]\"\r\n\r\ntrue\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[company][name]\"\r\n\r\nAcme Corp\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"nested_objects[company][employees]\"\r\n\r\n150\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[0][id]\"\r\n\r\n1\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[0][name]\"\r\n\r\nItem One\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[0][tags][0]\"\r\n\r\ntag1\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[0][tags][1]\"\r\n\r\ntag2\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[1][id]\"\r\n\r\n2\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[1][name]\"\r\n\r\nItem Two\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[1][tags][0]\"\r\n\r\ntag3\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"mixed_arrays[1][tags][1]\"\r\n\r\ntag4\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"sparse_array[0]\"\r\n\r\nfirst\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"sparse_array[2]\"\r\n\r\nthird\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"sparse_array[5]\"\r\n\r\nsixth\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"string_keys[first_key]\"\r\n\r\nfirst_value\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"string_keys[second_key]\"\r\n\r\nsecond_value\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"numeric_keys[0]\"\r\n\r\nzero_value\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"numeric_keys[1]\"\r\n\r\none_value\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"numeric_keys[10]\"\r\n\r\nten_value\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"empty_values[empty_string]\"\r\n\r\n\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"empty_values[zero_string]\"\r\n\r\n0\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"empty_values[false_string]\"\r\n\r\nfalse\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"empty_values[null_string]\"\r\n\r\nnull\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"complex_nesting[level1][level2][level3][items][0][name]\"\r\n\r\nDeep Item 1\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"complex_nesting[level1][level2][level3][items][0][value]\"\r\n\r\n100\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"complex_nesting[level1][level2][level3][items][1][name]\"\r\n\r\nDeep Item 2\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"complex_nesting[level1][level2][level3][items][1][value]\"\r\n\r\n200\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"complex_nesting[level1][level2][level3][metadata][count]\"\r\n\r\n2\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"complex_nesting[level1][level2][level3][metadata][enabled]\"\r\n\r\ntrue\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"duplicate_keys[0]\"\r\n\r\nfirst_duplicate\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"duplicate_keys[0]\"\r\n\r\nsecond_duplicate\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"duplicate_keys[0]\"\r\n\r\nthird_duplicate\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"special_chars[with spaces]\"\r\n\r\nvalue with spaces\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"special_chars[with-dashes]\"\r\n\r\nvalue-with-dashes\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"special_chars[with_underscores]\"\r\n\r\nvalue_with_underscores\r\n--complexBoundary\r\nContent-Disposition: form-data; name=\"special_chars[with.dots]\"\r\n\r\nvalue.with.dots\r\n--complexBoundary--\r\n"; + + $datav1 = [ + 'version' => '1.0', + 'resource' => '/path', + 'path' => '/path', + 'httpMethod' => 'POST', + 'headers' => [ + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip, deflate', + 'Cache-Control' => 'no-cache', + 'Content-Type' => 'multipart/form-data; boundary=complexBoundary', + 'Host' => 'example.org', + 'User-Agent' => 'PostmanRuntime/7.20.1', + 'X-Amzn-Trace-Id' => 'Root=1-ffffffff-ffffffffffffffffffffffff', + 'X-Forwarded-For' => '1.1.1.1', + 'X-Forwarded-Port' => '443', + 'X-Forwarded-Proto' => 'https', + ], + 'queryStringParameters' => null, + 'pathParameters' => null, + 'stageVariables' => null, + 'requestContext' => [ + 'resourceId' => 'xxxxxx', + 'resourcePath' => '/path', + 'httpMethod' => 'POST', + 'extendedRequestId' => 'XXXXXX-xxxxxxxx=', + 'requestTime' => '24/Nov/2019:18:55:08 +0000', + 'path' => '/path', + 'accountId' => '123400000000', + 'protocol' => 'HTTP/1.1', + 'stage' => 'dev', + 'domainPrefix' => 'dev', + 'requestTimeEpoch' => 1574621708700, + 'requestId' => 'ffffffff-ffff-4fff-ffff-ffffffffffff', + 'identity' => [ + 'cognitoIdentityPoolId' => null, + 'accountId' => null, + 'cognitoIdentityId' => null, + 'caller' => null, + 'sourceIp' => '1.1.1.1', + 'principalOrgId' => null, + 'accessKey' => null, + 'cognitoAuthenticationType' => null, + 'cognitoAuthenticationProvider' => null, + 'userArn' => null, + 'userAgent' => 'PostmanRuntime/7.20.1', + 'user' => null, + ], + 'domainName' => 'example.org', + 'apiId' => 'xxxxxxxxxx', + ], + 'body' => $body, + 'isBase64Encoded' => false, + ]; + + $datav2 = [ + 'version' => '2.0', + 'routeKey' => 'ANY /path', + 'rawPath' => '/path', + 'rawQueryString' => '', + 'headers' => [ + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip, deflate', + 'Cache-Control' => 'no-cache', + 'Content-Type' => 'multipart/form-data; boundary=complexBoundary', + 'Host' => 'example.org', + 'User-Agent' => 'PostmanRuntime/7.20.1', + 'X-Amzn-Trace-Id' => 'Root=1-ffffffff-ffffffffffffffffffffffff', + 'X-Forwarded-For' => '1.1.1.1', + 'X-Forwarded-Port' => '443', + 'X-Forwarded-Proto' => 'https', + ], + 'queryStringParameters' => null, + 'stageVariables' => null, + 'requestContext' => [ + 'accountId' => '123400000000', + 'apiId' => 'xxxxxxxxxx', + 'domainName' => 'example.org', + 'domainPrefix' => '0000000000', + 'http' => [ + 'method' => 'POST', + 'path' => '/path', + 'protocol' => 'HTTP/1.1', + 'sourceIp' => '1.1.1.1', + 'userAgent' => 'PostmanRuntime/7.20.1', + ], + 'requestId' => 'JTHoQgr2oAMEPMg=', + 'routeId' => '47matwk', + 'routeKey' => 'ANY /path', + 'stage' => '$default', + 'time' => '24/Nov/2019:18:55:08 +0000', + 'timeEpoch' => 1574621708700, + ], + 'body' => $body, + 'isBase64Encoded' => false, + ]; + + $expectedBody = [ + 'simple_string' => 'Hello World', + 'empty_string' => '', + 'numeric_string' => '12345', + 'boolean_string' => 'true', + 'indexed_array' => [ + 'first_item', + 'second_item', + 'third_item', + ], + 'associative_array' => [ + 'name' => 'John Doe', + 'age' => '30', + 'email' => 'john@example.com', + ], + 'nested_objects' => [ + 'user' => [ + 'profile' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'age' => '30', + ], + 'settings' => [ + 'theme' => 'dark', + 'notifications' => 'true', + ], + ], + 'company' => [ + 'name' => 'Acme Corp', + 'employees' => '150', + ], + ], + 'mixed_arrays' => [ + [ + 'id' => '1', + 'name' => 'Item One', + 'tags' => [ + 'tag1', + 'tag2', + ], + ], + [ + 'id' => '2', + 'name' => 'Item Two', + 'tags' => [ + 'tag3', + 'tag4', + ], + ], + ], + 'sparse_array' => [ + 0 => 'first', + 2 => 'third', + 5 => 'sixth', + ], + 'string_keys' => [ + 'first_key' => 'first_value', + 'second_key' => 'second_value', + ], + 'numeric_keys' => [ + 0 => 'zero_value', + 1 => 'one_value', + 10 => 'ten_value', + ], + 'empty_values' => [ + 'empty_string' => '', + 'zero_string' => '0', + 'false_string' => 'false', + 'null_string' => 'null', + ], + 'complex_nesting' => [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'items' => [ + [ + 'name' => 'Deep Item 1', + 'value' => '100', + ], + [ + 'name' => 'Deep Item 2', + 'value' => '200', + ], + ], + 'metadata' => [ + 'count' => '2', + 'enabled' => 'true', + ], + ], + ], + ], + ], + 'duplicate_keys' => [ + 'first_duplicate', + 'second_duplicate', + 'third_duplicate', + ], + 'special_chars' => [ + 'with spaces' => 'value with spaces', + 'with-dashes' => 'value-with-dashes', + 'with_underscores' => 'value_with_underscores', + 'with.dots' => 'value.with.dots', + ], + ]; + + $eventv1 = new HttpRequestEvent($datav1); + $requestv1 = Psr7Bridge::convertRequest($eventv1, Context::fake()); + $this->assertEquals($expectedBody, $requestv1->getParsedBody()); + + $eventv2 = new HttpRequestEvent($datav2); + $requestv2 = Psr7Bridge::convertRequest($eventv2, Context::fake()); + $this->assertEquals($expectedBody, $requestv2->getParsedBody()); + } + protected function fromFixture(string $file): void { $event = new HttpRequestEvent(json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR));