Skip to content

Commit 47fa95d

Browse files
committed
fix: Add multipart form-data support for Laravel HTTP server
1 parent 0ed837a commit 47fa95d

5 files changed

Lines changed: 339 additions & 4 deletions

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"ext-sockets": "*",
1717
"amphp/amp": "^3.1.1",
1818
"amphp/http-server": "^3.4.3",
19+
"amphp/http-server-form-parser": "^2.0.0",
1920
"amphp/websocket-client": "^2.0.2",
2021
"pestphp/pest": "^4.3.1",
2122
"pestphp/pest-plugin": "^4.0.0",

src/Drivers/LaravelHttpServer.php

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44

55
namespace Pest\Browser\Drivers;
66

7+
use const UPLOAD_ERR_OK;
8+
79
use Amp\ByteStream\ReadableResourceStream;
810
use Amp\Http\Cookie\RequestCookie;
911
use Amp\Http\Server\DefaultErrorHandler;
12+
use Amp\Http\Server\FormParser\BufferedFile;
13+
use Amp\Http\Server\FormParser\Form;
1014
use Amp\Http\Server\HttpServer as AmpHttpServer;
1115
use Amp\Http\Server\HttpServerStatus;
1216
use Amp\Http\Server\Request as AmpRequest;
@@ -25,6 +29,7 @@
2529
use Pest\Browser\GlobalState;
2630
use Pest\Browser\Playwright\Playwright;
2731
use Psr\Log\NullLogger;
32+
use Symfony\Component\HttpFoundation\File\UploadedFile;
2833
use Symfony\Component\Mime\MimeTypes;
2934
use Throwable;
3035

@@ -239,11 +244,21 @@ private function handleRequest(AmpRequest $request): Response
239244

240245
$contentType = $request->getHeader('content-type') ?? '';
241246
$method = mb_strtoupper($request->getMethod());
242-
$rawBody = (string) $request->getBody();
243247
$parameters = [];
244-
if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) {
245-
parse_str($rawBody, $parameters);
248+
$files = [];
249+
250+
if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'multipart/form-data')) {
251+
[$parameters, $files] = $this->parseMultipartFormData($request);
252+
253+
$rawBody = '';
254+
} else {
255+
$rawBody = (string) $request->getBody();
256+
257+
if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) {
258+
parse_str($rawBody, $parameters);
259+
}
246260
}
261+
247262
$cookies = array_map(fn (RequestCookie $cookie): string => urldecode($cookie->getValue()), $request->getCookies());
248263
$cookies = array_merge($cookies, test()->prepareCookiesForRequest()); // @phpstan-ignore-line
249264
/** @var array<string, string> $serverVariables */
@@ -254,7 +269,7 @@ private function handleRequest(AmpRequest $request): Response
254269
$method,
255270
$parameters,
256271
$cookies,
257-
[], // @TODO files...
272+
$files,
258273
$serverVariables,
259274
$rawBody
260275
);
@@ -362,4 +377,178 @@ private function rewriteAssetUrl(string $content): string
362377

363378
return str_replace($this->originalAssetUrl, $this->url(), $content);
364379
}
380+
381+
/**
382+
* Parse multipart form data and return request parameters and files.
383+
*
384+
* @return array{array<int|string, mixed>, array<int|string, mixed>}
385+
*/
386+
private function parseMultipartFormData(AmpRequest $request): array
387+
{
388+
$form = Form::fromRequest($request);
389+
390+
$values = $form->getValues();
391+
$files = $form->getFiles();
392+
393+
return [
394+
$this->normalizeMultipartParameters($values),
395+
$this->normalizeMultipartFiles($files),
396+
];
397+
}
398+
399+
/**
400+
* Normalize multipart field values to a Symfony request-compatible array.
401+
*
402+
* @param array<string, list<string>> $fields
403+
* @return array<int|string, mixed>
404+
*/
405+
private function normalizeMultipartParameters(array $fields): array
406+
{
407+
$normalized = [];
408+
409+
foreach ($fields as $field => $values) {
410+
foreach ($values as $value) {
411+
$this->setFieldValue($normalized, $field, $value);
412+
}
413+
}
414+
415+
return $normalized;
416+
}
417+
418+
/**
419+
* Normalize multipart files to a Symfony request-compatible files array.
420+
*
421+
* @param array<string, list<BufferedFile>> $files
422+
* @return array<int|string, mixed>
423+
*/
424+
private function normalizeMultipartFiles(array $files): array
425+
{
426+
$normalized = [];
427+
428+
foreach ($files as $field => $fileEntries) {
429+
foreach ($fileEntries as $fileEntry) {
430+
$this->setFieldValue($normalized, $field, $this->createUploadedFile($fileEntry));
431+
}
432+
}
433+
434+
return $normalized;
435+
}
436+
437+
/**
438+
* @param array<int|string, mixed> $target
439+
*/
440+
private function setFieldValue(array &$target, string $field, string|UploadedFile $value): void
441+
{
442+
$segments = $this->fieldSegments($field);
443+
444+
if ($segments === []) {
445+
return;
446+
}
447+
448+
$this->setNestedFieldValue($target, $segments, $value);
449+
}
450+
451+
/**
452+
* @return list<string>
453+
*/
454+
private function fieldSegments(string $field): array
455+
{
456+
if (! str_contains($field, '[')) {
457+
return [$field];
458+
}
459+
460+
$segments = [];
461+
$head = mb_strstr($field, '[', true);
462+
463+
if ($head !== false && $head !== '') {
464+
$segments[] = $head;
465+
}
466+
467+
preg_match_all('/\[([^\]]*)\]/', $field, $matches);
468+
469+
foreach ($matches[1] as $segment) {
470+
$segments[] = $segment;
471+
}
472+
473+
return $segments;
474+
}
475+
476+
/**
477+
* @param array<int|string, mixed> $target
478+
* @param list<string> $segments
479+
*/
480+
private function setNestedFieldValue(array &$target, array $segments, string|UploadedFile $value): void
481+
{
482+
$segment = array_shift($segments);
483+
484+
if ($segment === null) {
485+
return;
486+
}
487+
488+
if ($segments === []) {
489+
if ($segment === '') {
490+
$target[] = $value;
491+
492+
return;
493+
}
494+
495+
if (! array_key_exists($segment, $target)) {
496+
$target[$segment] = $value;
497+
498+
return;
499+
}
500+
501+
if (! is_array($target[$segment])) {
502+
$target[$segment] = [$target[$segment]];
503+
}
504+
505+
$target[$segment][] = $value;
506+
507+
return;
508+
}
509+
510+
if ($segment === '') {
511+
$target[] = [];
512+
513+
$lastKey = array_key_last($target);
514+
if (! is_array($target[$lastKey])) {
515+
return;
516+
}
517+
518+
$this->setNestedFieldValue($target[$lastKey], $segments, $value);
519+
520+
return;
521+
}
522+
523+
if (! isset($target[$segment]) || ! is_array($target[$segment])) {
524+
$target[$segment] = [];
525+
}
526+
527+
$this->setNestedFieldValue($target[$segment], $segments, $value);
528+
}
529+
530+
private function createUploadedFile(object $fileEntry): UploadedFile
531+
{
532+
$tempPath = tempnam(sys_get_temp_dir(), 'pest-browser-upload-');
533+
assert($tempPath !== false, 'Failed to create temporary upload file.');
534+
535+
$contents = method_exists($fileEntry, 'getContents') ? $fileEntry->getContents() : '';
536+
$contents = is_string($contents) ? $contents : '';
537+
538+
file_put_contents($tempPath, $contents);
539+
540+
$clientFilename = method_exists($fileEntry, 'getName') ? $fileEntry->getName() : 'upload';
541+
$clientFilename = is_string($clientFilename) && $clientFilename !== '' ? $clientFilename : 'upload';
542+
543+
$mimeType = method_exists($fileEntry, 'getMimeType') ? $fileEntry->getMimeType() : 'application/octet-stream';
544+
$mimeType = is_string($mimeType) && $mimeType !== '' ? $mimeType : 'application/octet-stream';
545+
546+
return new UploadedFile(
547+
$tempPath,
548+
$clientFilename,
549+
$mimeType,
550+
UPLOAD_ERR_OK,
551+
true,
552+
);
553+
}
365554
}

tests/Fixtures/example.pdf

45.7 KB
Binary file not shown.

tests/Fixtures/lorem-ipsum.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sit amet bibendum dui. Phasellus viverra justo quam, eu sollicitudin felis commodo eu. Integer at elit commodo, egestas ipsum eu, vestibulum libero. Donec ut metus sed sapien lobortis ultricies sit amet ac quam. Proin vitae dui non tortor laoreet laoreet quis rutrum leo. Ut pharetra nec arcu gravida egestas. Duis eget nisl pharetra, fermentum sapien vel, tempus ligula. Sed fringilla feugiat gravida. Mauris efficitur eu metus non consectetur. Vestibulum faucibus lacus tellus, ut vulputate nisl dapibus nec. Nunc eleifend nisl urna, ut imperdiet velit tristique nec. Nunc vel lacus libero. Praesent et pretium metus. Interdum et malesuada fames ac ante ipsum primis in faucibus.
2+
3+
In urna tellus, interdum in molestie nec, venenatis ut turpis. Aliquam erat volutpat. Nulla dictum turpis et tellus iaculis egestas. Proin velit neque, posuere sit amet sapien eget, dapibus pharetra velit. Morbi ultricies arcu et orci tempus accumsan. Maecenas ullamcorper, tortor nec finibus iaculis, sapien ipsum varius turpis, sit amet rutrum turpis libero cursus lorem. Nam bibendum lectus odio, ut sollicitudin justo gravida in. Integer aliquam, dolor eu tempor rutrum, enim nisl fringilla eros, a eleifend lectus est in turpis.
4+
5+
Nulla interdum mi erat, cursus ultricies nisi vulputate non. Cras at neque ex. Sed sodales vehicula dolor, ut dignissim eros convallis et. Vestibulum ornare sagittis eleifend. Mauris fermentum orci est, sed venenatis ex tincidunt id. Morbi ut nisi sagittis, egestas quam in, ornare tellus. Mauris molestie tincidunt lorem ac volutpat.
6+
7+
Duis fermentum, tellus vitae mollis finibus, diam augue pretium sapien, sed mattis urna lorem a leo. Sed quis enim finibus, finibus dui sit amet, luctus elit. Nullam velit enim, fermentum sit amet sem vitae, elementum volutpat justo. Integer porttitor tellus non dictum vulputate. Donec vulputate eu mi sit amet maximus. Donec egestas metus sit amet ante egestas, in suscipit elit dapibus. Proin magna massa, fringilla placerat facilisis vel, eleifend quis erat. Praesent ultrices lectus ac condimentum consectetur.
8+
9+
Ut vel venenatis augue, nec luctus neque. Nullam commodo urna a felis tempor posuere. Duis nec nisl eget leo efficitur feugiat. Nulla elementum massa a sapien finibus fermentum. Quisque pretium ligula in sapien auctor, in rhoncus ex eleifend. Sed id bibendum orci, sed ultrices sem. Curabitur sollicitudin quam odio, dignissim lacinia dolor ultrices vel.

tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
use Illuminate\Http\Request;
6+
use Illuminate\Support\Facades\Route;
67

78
use function Pest\Laravel\withServerVariables;
89
use function Pest\Laravel\withUnencryptedCookie;
@@ -36,3 +37,138 @@
3637
visit('/server-variables')
3738
->assertSee('"test-server-key":"test value"');
3839
});
40+
41+
it('parse a URL-encoded body', function (): void {
42+
Route::get('/', static fn (): string => "
43+
<html>
44+
<head></head>
45+
<body>
46+
<form method='post' action='/form'>
47+
<label for='name'>Your name</label>
48+
<input id='name' type='text' name='name'>
49+
50+
<button type='submit'>Send</button>
51+
</form>
52+
</body>
53+
</html>
54+
");
55+
Route::post('/form', static fn (Request $request): string => "
56+
<html>
57+
<head></head>
58+
<body>
59+
<h1>Hello {$request->post('name')}</h1>
60+
</body>
61+
</html>
62+
");
63+
64+
$page = visit('/');
65+
$page->assertSee('Your name');
66+
67+
$page->fill('Your name', 'World');
68+
$page->click('Send');
69+
70+
$page->assertSee('Hello World');
71+
});
72+
73+
it('parse a multipart body with files', function (): void {
74+
Route::get('favicon.ico', static fn (): string => '');
75+
Route::get('/', static fn (): string => "
76+
<html>
77+
<head></head>
78+
<body>
79+
<form method='post' enctype='multipart/form-data' action='/form'>
80+
<label for='name'>Your name</label>
81+
<input id='name' type='text' name='name'>
82+
83+
<label for='file1'>Your text file</label>
84+
<input id='file1' type='file' name='file1'>
85+
86+
<label for='file2'>Your binary file</label>
87+
<input id='file2' type='file' name='file2'>
88+
89+
<label for='file3'>Your empty file</label>
90+
<input id='file3' type='file' name='file3'>
91+
92+
<button type='submit'>Send</button>
93+
</form>
94+
</body>
95+
</html>
96+
");
97+
Route::post('/form', static fn (Request $request): string => "
98+
<html>
99+
<head></head>
100+
<body>
101+
<h1>Hello {$request->post('name')}</h1>
102+
<p>Text file: {$request->file('file1')?->getClientOriginalName()}</p>
103+
<p>Binary file: {$request->file('file2')?->getClientOriginalName()}</p>
104+
<p>Empty file: {$request->file('file3')?->getClientOriginalName()}</p>
105+
</body>
106+
</html>
107+
");
108+
109+
$page = visit('/');
110+
$page->assertSee('Your name');
111+
112+
$page->fill('Your name', 'World');
113+
$page->attach('Your text file', fixture('lorem-ipsum.txt'));
114+
$page->attach('Your binary file', fixture('example.pdf'));
115+
$page->submit();
116+
117+
$page->assertSee('Hello World');
118+
$page->assertSee('Text file: lorem-ipsum.txt');
119+
$page->assertSee('Binary file: example.pdf');
120+
$page->assertSee('Empty file: ');
121+
});
122+
123+
it('parse a multipart body with nested fields', function (): void {
124+
Route::get('/', static fn (): string => "
125+
<html>
126+
<head></head>
127+
<body>
128+
<form method='post' enctype='multipart/form-data' action='/form'>
129+
<label for='person-first-name'>Your first name</label>
130+
<input id='person-first-name' type='text' name='person[first_name]'>
131+
132+
<label for='person-last-name'>Your last name</label>
133+
<input id='person-last-name' type='text' name='person[last_name]'>
134+
135+
<label for='children-name-1'>Child 1</label>
136+
<input id='children-name-1' type='text' name='children[]'>
137+
138+
<label for='children-name-2'>Child 2</label>
139+
<input id='children-name-2' type='text' name='children[]'>
140+
141+
<label for='children-name-3'>Child 3</label>
142+
<input id='children-name-3' type='text' name='children[]'>
143+
144+
<button type='submit'>Send</button>
145+
</form>
146+
</body>
147+
</html>
148+
");
149+
Route::post('/form', static function (Request $request): string {
150+
$childNames = implode(', ', (array) $request->input('children'));
151+
152+
return "
153+
<html>
154+
<head></head>
155+
<body>
156+
<h1>Hello {$request->input('person.first_name')} {$request->input('person.last_name')}</h1>
157+
<p>and $childNames</p>
158+
</body>
159+
</html>
160+
";
161+
});
162+
163+
$page = visit('/');
164+
165+
$page->fill('Your first name', 'Jane');
166+
$page->fill('Your last name', 'Doe');
167+
$page->fill('Child 2', 'Johnathan');
168+
$page->fill('Child 3', 'Jamie');
169+
$page->fill('Child 1', 'John');
170+
$page->submit();
171+
172+
$page->assertSee('Hello Jane Doe')
173+
->assertSee('and John, Johnathan, Jamie');
174+
});

0 commit comments

Comments
 (0)