Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 45f3844

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

File tree

5 files changed

+207
-18
lines changed

5 files changed

+207
-18
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: 109 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,122 @@ 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 = ($i ? "\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+
$v = $streams[$v];
368+
369+
if (!\is_array($m = @stream_get_meta_data($v))) {
370+
throw new TransportException(sprintf('Invalid "%s" resource found in body part "%s".', get_resource_type($v), $k));
371+
}
372+
if (feof($v)) {
373+
throw new TransportException(sprintf('Uploaded stream ended for body part "%s".', $k));
374+
}
375+
376+
$m += stream_context_get_options($v)['http'] ?? [];
377+
$filename = basename($m['filename'] ?? $m['uri'] ?? 'unknown');
378+
$filename = str_replace(['"', "\r", "\n"], ['%22', '%0D', '%0A'], $filename);
379+
$contentType = $m['content_type'] ?? null;
380+
381+
if (($m = $m['wrapper_data'] ?? []) instanceof StreamWrapper) {
382+
$hasContentLength = false;
383+
$m = $m->getResponse()->getInfo('response_headers');
384+
} elseif ($hasContentLength = 0 < $h = fstat($v)['size'] ?? 0) {
385+
$contentLength += 0 <= $contentLength ? $h : 0;
386+
}
387+
388+
foreach (\is_array($m) ? $m : [] as $h) {
389+
if (\is_string($h) && 0 === stripos($h, 'Content-Type: ')) {
390+
$contentType ??= substr($h, 14);
391+
} elseif (!$hasContentLength && \is_string($h) && 0 === stripos($h, 'Content-Length: ')) {
392+
$hasContentLength = true;
393+
$contentLength += 0 <= $contentLength ? substr($h, 16) : 0;
394+
} elseif (\is_string($h) && 0 === stripos($h, 'Content-Encoding: ')) {
395+
$contentLength = -1;
396+
}
397+
}
398+
399+
if (!$hasContentLength) {
400+
$contentLength = -1;
401+
}
402+
$contentType ??= 'application/octet-stream';
403+
404+
$part .= "Content-Disposition: form-data; name=\"{$k}\"; filename=\"{$filename}\"\r\n";
405+
$part .= "Content-Type: {$contentType}\r\n\r\n";
406+
407+
$contentLength += 0 <= $contentLength ? \strlen($part) : 0;
408+
$body[$i] = [$k, $part, $v];
409+
}
410+
411+
$body[++$i] = ['', "\r\n--{$boundary}--\r\n", null];
412+
413+
if (0 < $contentLength) {
414+
$normalizedHeaders['content-length'] = ['Content-Length: '.($contentLength += \strlen($body[$i][1]))];
415+
}
416+
417+
$body = static function ($size) use ($body) {
418+
foreach ($body as [$k, $part, $h]) {
419+
yield $part;
420+
421+
while (null !== $h && !feof($h)) {
422+
if (false === $part = fread($h, $size)) {
423+
throw new TransportException(sprintf('Error while reading uploaded stream for body part "%s".', $k));
424+
}
425+
426+
yield $part;
427+
}
428+
}
429+
};
331430
}
332431

333432
if (\is_string($body)) {

src/Symfony/Component/HttpClient/Internal/CurlClientState.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ final class CurlClientState extends ClientState
3333
public array $pauseExpiries = [];
3434
public int $execCounter = \PHP_INT_MIN;
3535
public ?LoggerInterface $logger = null;
36+
public bool $performing = false;
3637

3738
public static array $curlVersion;
3839

src/Symfony/Component/HttpClient/Response/CurlResponse.php

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ final class CurlResponse implements ResponseInterface, StreamableInterface
3232
}
3333
use TransportResponseTrait;
3434

35-
private static bool $performing = false;
3635
private CurlClientState $multi;
3736

3837
/**
@@ -182,7 +181,7 @@ public function __construct(CurlClientState $multi, \CurlHandle|string $ch, arra
182181
unset($multi->pauseExpiries[$id], $multi->openHandles[$id], $multi->handlesActivity[$id]);
183182
curl_setopt($ch, \CURLOPT_PRIVATE, '_0');
184183

185-
if (self::$performing) {
184+
if ($multi->performing) {
186185
return;
187186
}
188187

@@ -234,13 +233,13 @@ public function getInfo(string $type = null): mixed
234233

235234
public function getContent(bool $throw = true): string
236235
{
237-
$performing = self::$performing;
238-
self::$performing = $performing || '_0' === curl_getinfo($this->handle, \CURLINFO_PRIVATE);
236+
$performing = $this->multi->performing;
237+
$this->multi->performing = $performing || '_0' === curl_getinfo($this->handle, \CURLINFO_PRIVATE);
239238

240239
try {
241240
return $this->doGetContent($throw);
242241
} finally {
243-
self::$performing = $performing;
242+
$this->multi->performing = $performing;
244243
}
245244
}
246245

@@ -279,7 +278,7 @@ private static function schedule(self $response, array &$runningResponses): void
279278
*/
280279
private static function perform(ClientState $multi, array &$responses = null): void
281280
{
282-
if (self::$performing) {
281+
if ($multi->performing) {
283282
if ($responses) {
284283
$response = current($responses);
285284
$multi->handlesActivity[(int) $response->handle][] = null;
@@ -290,7 +289,7 @@ private static function perform(ClientState $multi, array &$responses = null): v
290289
}
291290

292291
try {
293-
self::$performing = true;
292+
$multi->performing = true;
294293
++$multi->execCounter;
295294
$active = 0;
296295
while (\CURLM_CALL_MULTI_PERFORM === ($err = curl_multi_exec($multi->handle, $active))) {
@@ -327,7 +326,7 @@ private static function perform(ClientState $multi, array &$responses = null): v
327326
$multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || '_0' === $waitFor || curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD) ? null : new TransportException(ucfirst(curl_error($ch) ?: curl_strerror($result)).sprintf(' for "%s".', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
328327
}
329328
} finally {
330-
self::$performing = false;
329+
$multi->performing = false;
331330
}
332331
}
333332

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)