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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ node_modules/
package-lock.json
/.claude
/.playwright-mcp
.phpunit.result.cache
20 changes: 3 additions & 17 deletions src/Event/Http/Psr7Bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
168 changes: 168 additions & 0 deletions src/Support/MultipartArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php declare(strict_types=1);

namespace Bref\Support;

/**
* Build nested arrays from multipart form field names with bracket notation.
*
* Adapted from Laravel Vapor's {@see https://github.com/laravel/vapor-core/blob/2.0/src/Arr.php Arr::setMultipartArrayValue}.
*/
final class MultipartArray
{
/**
* @param array<string, mixed> $array
* @return array<string, mixed>
*/
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<string, mixed> $array
* @return array<string, mixed>
*/
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<string, mixed> $array
* @return array<string, mixed>
*/
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<string, mixed> $array
* @return array<string, mixed>
*/
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<string, mixed> $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;
}
}
34 changes: 34 additions & 0 deletions tests/Event/Http/CommonHttpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<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