|
13 | 13 |
|
14 | 14 | use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
|
15 | 15 | 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; |
16 | 19 |
|
17 | 20 | /**
|
18 | 21 | * Provides the common logic from writing HttpClientInterface implementations.
|
@@ -94,11 +97,7 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
|
94 | 97 | }
|
95 | 98 |
|
96 | 99 | 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']); |
102 | 101 |
|
103 | 102 | if (\is_string($options['body'])
|
104 | 103 | && (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
|
@@ -313,21 +312,129 @@ private static function normalizeHeaders(array $headers): array
|
313 | 312 | *
|
314 | 313 | * @throws InvalidArgumentException When an invalid body is passed
|
315 | 314 | */
|
316 |
| - private static function normalizeBody($body) |
| 315 | + private static function normalizeBody($body, array &$normalizedHeaders = []) |
317 | 316 | {
|
318 | 317 | 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)) { |
321 | 328 | if ($vars = get_object_vars($v)) {
|
322 | 329 | array_walk_recursive($vars, $caster);
|
323 | 330 | $v = $vars;
|
324 |
| - } elseif (method_exists($v, '__toString')) { |
| 331 | + } elseif ($v instanceof \Stringable) { |
325 | 332 | $v = (string) $v;
|
326 | 333 | }
|
327 | 334 | }
|
328 | 335 | });
|
329 | 336 |
|
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 | + }; |
331 | 438 | }
|
332 | 439 |
|
333 | 440 | if (\is_string($body)) {
|
|
0 commit comments