diff --git a/src/Event/Http/Psr7Bridge.php b/src/Event/Http/Psr7Bridge.php index 1745fdd86..94ecc9784 100644 --- a/src/Event/Http/Psr7Bridge.php +++ b/src/Event/Http/Psr7Bridge.php @@ -13,6 +13,20 @@ use function str_starts_with; +// Polyfill for array_is_list (PHP 8.1+) to support PHP 8.0 +if (! function_exists('array_is_list')) { + function array_is_list(array $array): bool + { + $i = 0; + foreach ($array as $key => $value) { + if ($key !== $i++) { + return false; + } + } + return true; + } +} + /** * Bridges PSR-7 requests and responses with API Gateway or ALB event/response formats. */ @@ -146,8 +160,78 @@ private static function parseKeyAndInsertValueInArray(array &$array, string $key 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); + + // Use a custom merge that handles both structured arrays and regular arrays + $array = self::mergeRecursivePreserveNumeric($array, $parsed); + } + + private static function mergeRecursivePreserveNumeric(array $a, array $b): array + { + foreach ($b as $key => $bVal) { + if (! array_key_exists($key, $a)) { + $a[$key] = $bVal; + continue; + } + + $aVal = $a[$key]; + + if (is_array($aVal) && is_array($bVal)) { + $aIsList = array_is_list($aVal); + $bIsList = array_is_list($bVal); + + if ($aIsList && $bIsList) { + // Determine whether list items are arrays (objects) -> merge-by-index + $mergeByIndex = false; + foreach ($aVal as $item) { + if (is_array($item)) { + $mergeByIndex = true; + break; + } + } + if (! $mergeByIndex) { + foreach ($bVal as $item) { + if (is_array($item)) { + $mergeByIndex = true; + break; + } + } + } + + if ($mergeByIndex) { + $max = max(count($aVal), count($bVal)); + $merged = []; + for ($i = 0; $i < $max; $i++) { + $hasA = array_key_exists($i, $aVal); + $hasB = array_key_exists($i, $bVal); + if ($hasA && $hasB) { + if (is_array($aVal[$i]) && is_array($bVal[$i])) { + $merged[$i] = self::mergeRecursivePreserveNumeric($aVal[$i], $bVal[$i]); + } else { + // if one is scalar, b wins + $merged[$i] = $bVal[$i]; + } + } elseif ($hasA) { + $merged[$i] = $aVal[$i]; + } else { + $merged[$i] = $bVal[$i]; + } + } + $a[$key] = $merged; + } else { + // both lists of scalars -> append + $a[$key] = array_merge($aVal, $bVal); + } + } else { + // At least one side is associative -> merge recursively by key + $a[$key] = self::mergeRecursivePreserveNumeric($aVal, $bVal); + } + } else { + // Non-array or conflicting types -> b wins + $a[$key] = $bVal; + } + } + + return $a; } /** diff --git a/tests/Event/Http/CommonHttpTest.php b/tests/Event/Http/CommonHttpTest.php index 1c4805901..bb9f393d9 100644 --- a/tests/Event/Http/CommonHttpTest.php +++ b/tests/Event/Http/CommonHttpTest.php @@ -420,6 +420,41 @@ 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) + { + var_dump($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));