Skip to content

Commit 357acda

Browse files
[HttpClient] Support file uploads by nesting resource streams in option "body"
1 parent b87f191 commit 357acda

File tree

3 files changed

+207
-10
lines changed

3 files changed

+207
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Add `ServerSentEvent::getArrayData()` to get the Server-Sent Event's data decoded as an array when it's a JSON payload
99
* Allow array of urls as `base_uri` option value in `RetryableHttpClient` to retry on a new url each time
1010
* Add `JsonMockResponse`, a `MockResponse` shortcut that automatically encodes the passed body to JSON and sets the content type to `application/json` by default
11+
* Support file uploads by nesting resource streams in option "body"
1112

1213
6.2
1314
---

HttpClientTrait.php

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
1515
use Symfony\Component\HttpClient\Exception\TransportException;
16+
use Symfony\Component\HttpClient\Response\StreamableInterface;
17+
use Symfony\Component\HttpClient\Response\StreamWrapper;
18+
use Symfony\Component\Mime\MimeTypes;
1619

1720
/**
1821
* Provides the common logic from writing HttpClientInterface implementations.
@@ -94,11 +97,7 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
9497
}
9598

9699
if (isset($options['body'])) {
97-
if (\is_array($options['body']) && (!isset($options['normalized_headers']['content-type'][0]) || !str_contains($options['normalized_headers']['content-type'][0], 'application/x-www-form-urlencoded'))) {
98-
$options['normalized_headers']['content-type'] = ['Content-Type: application/x-www-form-urlencoded'];
99-
}
100-
101-
$options['body'] = self::normalizeBody($options['body']);
100+
$options['body'] = self::normalizeBody($options['body'], $options['normalized_headers']);
102101

103102
if (\is_string($options['body'])
104103
&& (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
@@ -313,21 +312,129 @@ private static function normalizeHeaders(array $headers): array
313312
*
314313
* @throws InvalidArgumentException When an invalid body is passed
315314
*/
316-
private static function normalizeBody($body)
315+
private static function normalizeBody($body, array &$normalizedHeaders = [])
317316
{
318317
if (\is_array($body)) {
319-
array_walk_recursive($body, $caster = static function (&$v) use (&$caster) {
320-
if (\is_object($v)) {
318+
static $cookie;
319+
320+
$streams = [];
321+
array_walk_recursive($body, $caster = static function (&$v) use (&$caster, &$streams, &$cookie) {
322+
if (\is_resource($v) || $v instanceof StreamableInterface) {
323+
$cookie = hash('xxh128', $cookie ??= random_bytes(8), true);
324+
$k = substr(strtr(base64_encode($cookie), '+/', '-_'), 0, -2);
325+
$streams[$k] = $v instanceof StreamableInterface ? $v->toStream(false) : $v;
326+
$v = $k;
327+
} elseif (\is_object($v)) {
321328
if ($vars = get_object_vars($v)) {
322329
array_walk_recursive($vars, $caster);
323330
$v = $vars;
324-
} elseif (method_exists($v, '__toString')) {
331+
} elseif ($v instanceof \Stringable) {
325332
$v = (string) $v;
326333
}
327334
}
328335
});
329336

330-
return http_build_query($body, '', '&');
337+
$body = http_build_query($body, '', '&');
338+
339+
if ('' === $body || !$streams && !str_contains($normalizedHeaders['content-type'][0] ?? '', 'multipart/form-data')) {
340+
if (!str_contains($normalizedHeaders['content-type'][0] ?? '', 'application/x-www-form-urlencoded')) {
341+
$normalizedHeaders['content-type'] = ['Content-Type: application/x-www-form-urlencoded'];
342+
}
343+
344+
return $body;
345+
}
346+
347+
if (preg_match('{multipart/form-data; boundary=(?|"([^"\r\n]++)"|([-!#$%&\'*+.^_`|~_A-Za-z0-9]++))}', $normalizedHeaders['content-type'][0] ?? '', $boundary)) {
348+
$boundary = $boundary[1];
349+
} else {
350+
$boundary = substr(strtr(base64_encode($cookie ??= random_bytes(8)), '+/', '-_'), 0, -2);
351+
$normalizedHeaders['content-type'] = ['Content-Type: multipart/form-data; boundary='.$boundary];
352+
}
353+
354+
$body = explode('&', $body);
355+
$contentLength = 0;
356+
357+
foreach ($body as $i => $part) {
358+
[$k, $v] = explode('=', $part, 2);
359+
$part = ($i ? "\r\n" : '')."--{$boundary}\r\n";
360+
$k = str_replace(['"', "\r", "\n"], ['%22', '%0D', '%0A'], urldecode($k)); // see WHATWG HTML living standard
361+
362+
if (!isset($streams[$v])) {
363+
$part .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n".urldecode($v);
364+
$contentLength += 0 <= $contentLength ? \strlen($part) : 0;
365+
$body[$i] = [$k, $part, null];
366+
continue;
367+
}
368+
$v = $streams[$v];
369+
370+
if (!\is_array($m = @stream_get_meta_data($v))) {
371+
throw new TransportException(sprintf('Invalid "%s" resource found in body part "%s".', get_resource_type($v), $k));
372+
}
373+
if (feof($v)) {
374+
throw new TransportException(sprintf('Uploaded stream ended for body part "%s".', $k));
375+
}
376+
377+
$m += stream_context_get_options($v)['http'] ?? [];
378+
$filename = basename($m['filename'] ?? $m['uri'] ?? 'unknown');
379+
$filename = str_replace(['"', "\r", "\n"], ['%22', '%0D', '%0A'], $filename);
380+
$contentType = $m['content_type'] ?? null;
381+
382+
if (($headers = $m['wrapper_data'] ?? []) instanceof StreamWrapper) {
383+
$hasContentLength = false;
384+
$headers = $headers->getResponse()->getInfo('response_headers');
385+
} elseif ($hasContentLength = 0 < $h = fstat($v)['size'] ?? 0) {
386+
$contentLength += 0 <= $contentLength ? $h : 0;
387+
}
388+
389+
foreach (\is_array($headers) ? $headers : [] as $h) {
390+
if (\is_string($h) && 0 === stripos($h, 'Content-Type: ')) {
391+
$contentType ??= substr($h, 14);
392+
} elseif (!$hasContentLength && \is_string($h) && 0 === stripos($h, 'Content-Length: ')) {
393+
$hasContentLength = true;
394+
$contentLength += 0 <= $contentLength ? substr($h, 16) : 0;
395+
} elseif (\is_string($h) && 0 === stripos($h, 'Content-Encoding: ')) {
396+
$contentLength = -1;
397+
}
398+
}
399+
400+
if (!$hasContentLength) {
401+
$contentLength = -1;
402+
}
403+
if (null === $contentType && 'plainfile' === ($m['wrapper_type'] ?? null) && isset($m['uri'])) {
404+
$mimeTypes = class_exists(MimeTypes::class) ? MimeTypes::getDefault() : false;
405+
$contentType = $mimeTypes ? $mimeTypes->guessMimeType($m['uri']) : null;
406+
}
407+
$contentType ??= 'application/octet-stream';
408+
409+
$part .= "Content-Disposition: form-data; name=\"{$k}\"; filename=\"{$filename}\"\r\n";
410+
$part .= "Content-Type: {$contentType}\r\n\r\n";
411+
412+
$contentLength += 0 <= $contentLength ? \strlen($part) : 0;
413+
$body[$i] = [$k, $part, $v];
414+
}
415+
416+
$body[++$i] = ['', "\r\n--{$boundary}--\r\n", null];
417+
418+
if (0 < $contentLength) {
419+
$normalizedHeaders['content-length'] = ['Content-Length: '.($contentLength += \strlen($body[$i][1]))];
420+
}
421+
422+
$body = static function ($size) use ($body) {
423+
foreach ($body as $i => [$k, $part, $h]) {
424+
unset($body[$i]);
425+
426+
yield $part;
427+
428+
while (null !== $h && !feof($h)) {
429+
if (false === $part = fread($h, $size)) {
430+
throw new TransportException(sprintf('Error while reading uploaded stream for body part "%s".', $k));
431+
}
432+
433+
yield $part;
434+
}
435+
}
436+
$h = null;
437+
};
331438
}
332439

333440
if (\is_string($body)) {

Tests/HttpClientTraitTest.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
16+
use Symfony\Component\HttpClient\HttpClient;
1617
use Symfony\Component\HttpClient\HttpClientTrait;
1718
use Symfony\Contracts\HttpClient\HttpClientInterface;
1819

@@ -68,6 +69,94 @@ public function testPrepareRequestWithBodyIsArray()
6869
$this->assertContains('Content-Type: application/x-www-form-urlencoded; charset=utf-8', $options['headers']);
6970
}
7071

72+
public function testNormalizeBodyMultipart()
73+
{
74+
$file = fopen('php://memory', 'r+');
75+
stream_context_set_option($file, ['http' => [
76+
'filename' => 'test.txt',
77+
'content_type' => 'text/plain',
78+
]]);
79+
fwrite($file, 'foobarbaz');
80+
rewind($file);
81+
82+
$headers = [
83+
'content-type' => ['Content-Type: multipart/form-data; boundary=ABCDEF'],
84+
];
85+
$body = [
86+
'foo[]' => 'bar',
87+
'bar' => [
88+
$file,
89+
],
90+
];
91+
92+
$body = self::normalizeBody($body, $headers);
93+
94+
$result = '';
95+
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
96+
$result .= $data;
97+
}
98+
99+
$expected = <<<'EOF'
100+
--ABCDEF
101+
Content-Disposition: form-data; name="foo[]"
102+
103+
bar
104+
--ABCDEF
105+
Content-Disposition: form-data; name="bar[0]"; filename="test.txt"
106+
Content-Type: text/plain
107+
108+
foobarbaz
109+
--ABCDEF--
110+
111+
EOF;
112+
$expected = str_replace("\n", "\r\n", $expected);
113+
114+
$this->assertSame($expected, $result);
115+
}
116+
117+
/**
118+
* @group network
119+
*
120+
* @dataProvider provideNormalizeBodyMultipartForwardStream
121+
*/
122+
public function testNormalizeBodyMultipartForwardStream($stream)
123+
{
124+
$body = [
125+
'logo' => $stream,
126+
];
127+
128+
$headers = [];
129+
$body = self::normalizeBody($body, $headers);
130+
131+
$result = '';
132+
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
133+
$result .= $data;
134+
}
135+
136+
$this->assertSame(1, preg_match('/^Content-Type: multipart\/form-data; boundary=(?<boundary>.+)$/', $headers['content-type'][0], $matches));
137+
$this->assertSame('Content-Length: 3086', $headers['content-length'][0]);
138+
$this->assertSame(3086, \strlen($result));
139+
140+
$expected = <<<EOF
141+
--{$matches['boundary']}
142+
Content-Disposition: form-data; name="logo"; filename="1f44d.png"
143+
Content-Type: image/png
144+
145+
%A
146+
--{$matches['boundary']}--
147+
148+
EOF;
149+
$expected = str_replace("\n", "\r\n", $expected);
150+
151+
$this->assertStringMatchesFormat($expected, $result);
152+
}
153+
154+
public static function provideNormalizeBodyMultipartForwardStream()
155+
{
156+
yield 'native' => [fopen('https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png', 'r')];
157+
yield 'symfony' => [HttpClient::create()->request('GET', 'https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png')->toStream()];
158+
}
159+
71160
/**
72161
* @dataProvider provideResolveUrl
73162
*/

0 commit comments

Comments
 (0)