Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions src/Event/Http/Psr7Bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}

/**
Expand Down
35 changes: 35 additions & 0 deletions tests/Event/Http/CommonHttpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<h1>Test content</h1>',
'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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<h1>Test content</h1>\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
}
Original file line number Diff line number Diff line change
@@ -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<h1>Test content</h1>\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
}
Loading
Loading