Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit45f3844

Browse files
[HttpClient] Support file uploads by nesting resource streams in option "body"
1 parentcc7cdf2 commit45f3844

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
useSymfony\Component\HttpClient\Exception\InvalidArgumentException;
1515
useSymfony\Component\HttpClient\Exception\TransportException;
16+
useSymfony\Component\HttpClient\Response\StreamableInterface;
17+
useSymfony\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-
privatestaticfunctionnormalizeBody($body)
314+
privatestaticfunctionnormalizeBody($body,array &$normalizedHeaders = [])
317315
{
318316
if (\is_array($body)) {
319-
array_walk_recursive($body,$caster =staticfunction (&$v)use (&$caster) {
320-
if (\is_object($v)) {
317+
static$cookie;
318+
319+
$streams = [];
320+
array_walk_recursive($body,$caster =staticfunction (&$v)use (&$caster, &$streams, &$cookie) {
321+
if (\is_resource($v) ||$vinstanceof StreamableInterface) {
322+
$cookie =hash('xxh128',$cookie ??=random_bytes(8),true);
323+
$k =substr(strtr(base64_encode($cookie),'+/','-_'),0, -2);
324+
$streams[$k] =$vinstanceof 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 ($vinstanceof \Stringable) {
325331
$v = (string)$v;
326332
}
327333
}
328334
});
329335

330-
returnhttp_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 ($bodyas$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+
thrownewTransportException(sprintf('Invalid "%s" resource found in body part "%s".',get_resource_type($v),$k));
371+
}
372+
if (feof($v)) {
373+
thrownewTransportException(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 =staticfunction ($size)use ($body) {
418+
foreach ($bodyas [$k,$part,$h]) {
419+
yield$part;
420+
421+
while (null !==$h && !feof($h)) {
422+
if (false ===$part =fread($h,$size)) {
423+
thrownewTransportException(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
publicarray$pauseExpiries = [];
3434
publicint$execCounter = \PHP_INT_MIN;
3535
public ?LoggerInterface$logger =null;
36+
publicbool$performing =false;
3637

3738
publicstaticarray$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-
privatestaticbool$performing =false;
3635
privateCurlClientState$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
publicfunctiongetContent(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
privatestaticfunctionperform(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 :newTransportException(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
usePHPUnit\Framework\TestCase;
1515
useSymfony\Component\HttpClient\Exception\InvalidArgumentException;
16+
useSymfony\Component\HttpClient\HttpClient;
1617
useSymfony\Component\HttpClient\HttpClientTrait;
1718
useSymfony\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+
publicfunctiontestNormalizeBodyMultipart()
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+
publicfunctiontestNormalizeBodyMultipartForwardStream($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+
publicstaticfunctionprovideNormalizeBodyMultipartForwardStream()
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)

[8]ページ先頭

©2009-2025 Movatter.jp