Skip to content

Commit e957514

Browse files
committed
feature #49911 [HttpClient] Support file uploads by nesting resource streams in body option (nicolas-grekas)
This PR was merged into the 6.3 branch. Discussion ---------- [HttpClient] Support file uploads by nesting resource streams in `body` option | Q | A | ------------- | --- | Branch? | 6.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | - This PR makes it easy to upload files using `multipart/form-data`. Nesting streams in option "body" is all one needs to do: ```php $h = fopen('/path/to/the/file' 'r'); $client->request('POST', 'https://...', ['body' => ['the_file' => $h]]); ``` By default, the code will populate the `filename` using the base-name of the opened file. It's possible to override this by calling `stream_context_set_option($h, 'http', 'filename', 'the-name.txt')`. When forwarding an HTTP request coming either from the native `http` stream wrapper or from Symfony's `StreamableInterface`, the code will forward the content-type. It's also possible to set the content-type using `stream_context_set_option($h, 'http', 'content_type', 'my/content-type')`. When possible, the code will generate a `Content-Length` header. This enables the destination server to reject the request early if it's too big and it allows tracking the progress of uploads on the client-side. Commits ------- 9745982 [HttpClient] Support file uploads by nesting resource streams in option "body"
2 parents 3761915 + 9745982 commit e957514

File tree

3 files changed

+207
-10
lines changed

3 files changed

+207
-10
lines changed

src/Symfony/Component/HttpClient/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
---

src/Symfony/Component/HttpClient/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)) {

src/Symfony/Component/HttpClient/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)
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