Skip to content

Commit 5508bb5

Browse files
[HttpClient] Support file uploads by nesting resource streams in option "body"
1 parent cc7cdf2 commit 5508bb5

File tree

3 files changed

+199
-10
lines changed

3 files changed

+199
-10
lines changed

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Add `UriTemplateHttpClient` to use URI templates as specified in the RFC 6570
88
* Add `ServerSentEvent::getArrayData()` to get the Server-Sent Event's data decoded as an array when it's a JSON payload
9+
* Support file uploads by nesting resource streams in option "body"
910

1011
6.2
1112
---

src/Symfony/Component/HttpClient/HttpClientTrait.php

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
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;
1618

1719
/**
1820
* Provides the common logic from writing HttpClientInterface implementations.
@@ -94,11 +96,7 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
9496
}
9597

9698
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']);
99+
$options['body'] = self::normalizeBody($options['body'], $options['normalized_headers']);
102100

103101
if (\is_string($options['body'])
104102
&& (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
@@ -313,21 +311,120 @@ private static function normalizeHeaders(array $headers): array
313311
*
314312
* @throws InvalidArgumentException When an invalid body is passed
315313
*/
316-
private static function normalizeBody($body)
314+
private static function normalizeBody($body, array &$normalizedHeaders = [])
317315
{
318316
if (\is_array($body)) {
319-
array_walk_recursive($body, $caster = static function (&$v) use (&$caster) {
320-
if (\is_object($v)) {
317+
static $cookie;
318+
319+
$streams = [];
320+
array_walk_recursive($body, $caster = static function (&$v) use (&$caster, &$streams, &$cookie) {
321+
if (\is_resource($v) || $v instanceof StreamableInterface) {
322+
$cookie = hash('xxh128', $cookie ??= random_bytes(8), true);
323+
$k = substr(strtr(base64_encode($cookie), '+/', '-_'), 0, -2);
324+
$streams[$k] = $v instanceof StreamableInterface ? $v->toStream(false) : $v;
325+
$v = $k;
326+
} elseif (\is_object($v)) {
321327
if ($vars = get_object_vars($v)) {
322328
array_walk_recursive($vars, $caster);
323329
$v = $vars;
324-
} elseif (method_exists($v, '__toString')) {
330+
} elseif ($v instanceof \Stringable) {
325331
$v = (string) $v;
326332
}
327333
}
328334
});
329335

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

333430
if (\is_string($body)) {

src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php

Lines changed: 91 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,96 @@ 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+
101+
--ABCDEF
102+
Content-Disposition: form-data; name="foo[]"
103+
104+
bar
105+
--ABCDEF
106+
Content-Disposition: form-data; name="bar[0]"; filename="test.txt"
107+
Content-Type: text/plain
108+
109+
foobarbaz
110+
--ABCDEF--
111+
112+
EOF;
113+
$expected = str_replace("\n", "\r\n", $expected);
114+
115+
$this->assertSame($expected, $result);
116+
}
117+
118+
/**
119+
* @group network
120+
*
121+
* @dataProvider provideNormalizeBodyMultipartForwardStream
122+
*/
123+
public function testNormalizeBodyMultipartForwardStream($stream)
124+
{
125+
$body = [
126+
'logo' => $stream,
127+
];
128+
129+
$headers = [];
130+
$body = self::normalizeBody($body, $headers);
131+
132+
$result = '';
133+
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
134+
$result .= $data;
135+
}
136+
137+
$this->assertSame(1, preg_match('/^Content-Type: multipart\/form-data; boundary=(?<boundary>.+)$/', $headers['content-type'][0], $matches));
138+
$this->assertSame('Content-Length: 11025', $headers['content-length'][0]);
139+
$this->assertSame(11025, \strlen($result));
140+
141+
$expected = <<<EOF
142+
143+
--{$matches['boundary']}
144+
Content-Disposition: form-data; name="logo"; filename="symfony_black_03.png"
145+
Content-Type: image/png
146+
147+
%A
148+
--{$matches['boundary']}--
149+
150+
EOF;
151+
$expected = str_replace("\n", "\r\n", $expected);
152+
153+
$this->assertStringMatchesFormat($expected, $result);
154+
}
155+
156+
public static function provideNormalizeBodyMultipartForwardStream()
157+
{
158+
yield 'native' => [fopen('https://symfony.com/logos/symfony_black_03.png', 'r')];
159+
yield 'symfony' => [HttpClient::create()->request('GET', 'https://symfony.com/logos/symfony_black_03.png')->toStream()];
160+
}
161+
71162
/**
72163
* @dataProvider provideResolveUrl
73164
*/

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy