1: <?php declare(strict_types=1);
2:
3: namespace Salient\Curler;
4:
5: use Psr\Http\Client\ClientExceptionInterface;
6: use Psr\Http\Message\RequestInterface;
7: use Psr\Http\Message\ResponseInterface;
8: use Psr\Http\Message\StreamInterface;
9: use Psr\Http\Message\UriInterface as PsrUriInterface;
10: use Salient\Contract\Cache\CacheInterface;
11: use Salient\Contract\Core\Arrayable;
12: use Salient\Contract\Core\Buildable;
13: use Salient\Contract\Core\DateFormatterInterface;
14: use Salient\Contract\Core\MimeType;
15: use Salient\Contract\Curler\Exception\CurlErrorExceptionInterface;
16: use Salient\Contract\Curler\CurlerInterface;
17: use Salient\Contract\Curler\CurlerMiddlewareInterface;
18: use Salient\Contract\Curler\CurlerPageRequestInterface;
19: use Salient\Contract\Curler\CurlerPagerInterface;
20: use Salient\Contract\Http\AccessTokenInterface;
21: use Salient\Contract\Http\FormDataFlag;
22: use Salient\Contract\Http\HttpHeader;
23: use Salient\Contract\Http\HttpHeaderGroup;
24: use Salient\Contract\Http\HttpHeadersInterface;
25: use Salient\Contract\Http\HttpMultipartStreamInterface;
26: use Salient\Contract\Http\HttpRequestHandlerInterface;
27: use Salient\Contract\Http\HttpRequestMethod as Method;
28: use Salient\Contract\Http\HttpResponseInterface;
29: use Salient\Contract\Http\UriInterface;
30: use Salient\Core\Concern\HasBuilder;
31: use Salient\Core\Concern\HasMutator;
32: use Salient\Core\Facade\Cache;
33: use Salient\Core\Facade\Console;
34: use Salient\Core\Facade\Event;
35: use Salient\Curler\Event\CurlRequestEvent;
36: use Salient\Curler\Event\CurlResponseEvent;
37: use Salient\Curler\Event\ResponseCacheHitEvent;
38: use Salient\Curler\Exception\CurlErrorException;
39: use Salient\Curler\Exception\HttpErrorException;
40: use Salient\Curler\Exception\NetworkException;
41: use Salient\Curler\Exception\RequestException;
42: use Salient\Curler\Exception\TooManyRedirectsException;
43: use Salient\Http\Exception\InvalidHeaderException;
44: use Salient\Http\Exception\StreamEncapsulationException;
45: use Salient\Http\HasHttpHeaders;
46: use Salient\Http\Http;
47: use Salient\Http\HttpHeaders;
48: use Salient\Http\HttpRequest;
49: use Salient\Http\HttpResponse;
50: use Salient\Http\HttpStream;
51: use Salient\Http\Uri;
52: use Salient\Utility\Arr;
53: use Salient\Utility\File;
54: use Salient\Utility\Get;
55: use Salient\Utility\Inflect;
56: use Salient\Utility\Json;
57: use Salient\Utility\Str;
58: use Closure;
59: use CurlHandle;
60: use Generator;
61: use InvalidArgumentException;
62: use LogicException;
63: use OutOfRangeException;
64: use RuntimeException;
65: use Stringable;
66: use Throwable;
67:
68: /**
69: * A PSR-18 HTTP client optimised for exchanging data with RESTful API endpoints
70: *
71: * @api
72: *
73: * @implements Buildable<CurlerBuilder>
74: */
75: class Curler implements CurlerInterface, Buildable
76: {
77: /** @use HasBuilder<CurlerBuilder> */
78: use HasBuilder;
79: use HasHttpHeaders;
80: use HasMutator;
81:
82: /**
83: * Limit input strings to 2MiB
84: *
85: * The underlying limit, `CURL_MAX_INPUT_LENGTH`, is 8MB.
86: */
87: protected const MAX_INPUT_LENGTH = 2 * 1024 ** 2;
88:
89: protected const METHOD_HAS_BODY = [
90: Method::POST => true,
91: Method::PUT => true,
92: Method::PATCH => true,
93: Method::DELETE => true,
94: ];
95:
96: protected Uri $Uri;
97: protected HttpHeadersInterface $Headers;
98: protected ?AccessTokenInterface $AccessToken;
99: protected string $AccessTokenHeaderName;
100: /** @var array<string,true> */
101: protected array $SensitiveHeaders;
102: protected ?string $MediaType;
103: protected ?string $UserAgent;
104: protected bool $ExpectJson;
105: protected bool $PostJson;
106: protected ?DateFormatterInterface $DateFormatter;
107: /** @var int-mask-of<FormDataFlag::*> */
108: protected int $FormDataFlags;
109: /** @var int-mask-of<\JSON_BIGINT_AS_STRING|\JSON_INVALID_UTF8_IGNORE|\JSON_INVALID_UTF8_SUBSTITUTE|\JSON_OBJECT_AS_ARRAY|\JSON_THROW_ON_ERROR> */
110: protected int $JsonDecodeFlags;
111: /** @var array<array{CurlerMiddlewareInterface|HttpRequestHandlerInterface|Closure(RequestInterface $request, Closure(RequestInterface): HttpResponseInterface $next, CurlerInterface $curler): ResponseInterface,string|null}> */
112: protected array $Middleware = [];
113: protected ?CurlerPagerInterface $Pager;
114: protected bool $AlwaysPaginate;
115: protected ?CacheInterface $CacheStore;
116: protected ?string $CookiesCacheKey;
117: protected bool $CacheResponses;
118: protected bool $CachePostResponses;
119: /** @var (Closure(RequestInterface $request, CurlerInterface $curler): (string[]|string))|null */
120: protected ?Closure $CacheKeyClosure;
121: /** @var int<-1,max> */
122: protected int $CacheLifetime;
123: protected bool $RefreshCache;
124: /** @var int<0,max>|null */
125: protected ?int $Timeout;
126: protected bool $FollowRedirects;
127: /** @var int<-1,max>|null */
128: protected ?int $MaxRedirects;
129: protected bool $RetryAfterTooManyRequests;
130: /** @var int<0,max> */
131: protected int $RetryAfterMaxSeconds;
132: protected bool $ThrowHttpErrors;
133: protected ?RequestInterface $LastRequest = null;
134: protected ?HttpResponseInterface $LastResponse = null;
135: private ?Curler $WithoutThrowHttpErrors = null;
136: private ?Closure $Closure = null;
137: private static string $DefaultUserAgent;
138: /** @var array<string,true> */
139: private static array $UnstableHeaders;
140: /** @var CurlHandle|resource|null */
141: private static $Handle;
142:
143: /**
144: * Creates a new Curler object
145: *
146: * Parameters after `$uri` are not covered by the Salient toolkit's backward
147: * compatibility promise.
148: *
149: * @param PsrUriInterface|Stringable|string|null $uri Endpoint URI (cannot have query or fragment components)
150: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|null $headers Request headers
151: * @param AccessTokenInterface|null $accessToken Access token applied to request headers
152: * @param string $accessTokenHeaderName Name of access token header (default: `"Authorization"`)
153: * @param string[] $sensitiveHeaders Headers treated as sensitive (default: {@see HttpHeaderGroup::SENSITIVE})
154: * @param string|null $mediaType Media type applied to request headers
155: * @param string|null $userAgent User agent applied to request headers
156: * @param bool $expectJson Explicitly accept JSON-encoded responses and assume responses with no content type contain JSON
157: * @param bool $postJson Use JSON to encode POST/PUT/PATCH/DELETE data
158: * @param DateFormatterInterface|null $dateFormatter Date formatter used to format and parse the endpoint's date and time values
159: * @param int-mask-of<FormDataFlag::*> $formDataFlags Flags used to encode data for query strings and message bodies (default: {@see FormDataFlag::PRESERVE_NUMERIC_KEYS} `|` {@see FormDataFlag::PRESERVE_STRING_KEYS})
160: * @param int-mask-of<\JSON_BIGINT_AS_STRING|\JSON_INVALID_UTF8_IGNORE|\JSON_INVALID_UTF8_SUBSTITUTE|\JSON_OBJECT_AS_ARRAY|\JSON_THROW_ON_ERROR> $jsonDecodeFlags Flags used to decode JSON returned by the endpoint (default: {@see \JSON_OBJECT_AS_ARRAY})
161: * @param array<array{CurlerMiddlewareInterface|HttpRequestHandlerInterface|Closure(RequestInterface $request, Closure(RequestInterface): HttpResponseInterface $next, CurlerInterface $curler): ResponseInterface,1?:string|null}> $middleware Middleware applied to the request handler stack
162: * @param CurlerPagerInterface|null $pager Pagination handler
163: * @param bool $alwaysPaginate Use the pager to process requests even if no pagination is required
164: * @param CacheInterface|null $cacheStore Cache store used for cookie and response caching instead of the {@see Cache} facade's underlying store
165: * @param bool $handleCookies Enable cookie handling
166: * @param string|null $cookiesCacheKey Key to cache cookies under (cookie handling is implicitly enabled if given)
167: * @param bool $cacheResponses Cache responses to GET and HEAD requests (HTTP caching headers are ignored; USE RESPONSIBLY)
168: * @param bool $cachePostResponses Cache responses to repeatable POST requests (ignored if GET and HEAD request caching is disabled)
169: * @param (callable(RequestInterface $request, CurlerInterface $curler): (string[]|string))|null $cacheKeyCallback Override values hashed and combined with request method and URI to create response cache keys (headers not in {@see HttpHeaderGroup::UNSTABLE} are used by default)
170: * @param int<-1,max> $cacheLifetime Seconds before cached responses expire when caching is enabled (`0` = cache indefinitely; `-1` = do not cache; default: `3600`)
171: * @param bool $refreshCache Replace cached responses even if they haven't expired
172: * @param int<0,max>|null $timeout Connection timeout in seconds (`null` = use underlying default of `300` seconds; default: `null`)
173: * @param bool $followRedirects Follow "Location" headers
174: * @param int<-1,max>|null $maxRedirects Limit the number of "Location" headers followed (`-1` = unlimited; `0` = do not follow redirects; `null` = use underlying default of `30`; default: `null`)
175: * @param bool $retryAfterTooManyRequests Retry throttled requests when the endpoint returns a "Retry-After" header
176: * @param int<0,max> $retryAfterMaxSeconds Limit the delay between request attempts (`0` = unlimited; default: `300`)
177: * @param bool $throwHttpErrors Throw exceptions for HTTP errors
178: */
179: public function __construct(
180: $uri = null,
181: $headers = null,
182: ?AccessTokenInterface $accessToken = null,
183: string $accessTokenHeaderName = HttpHeader::AUTHORIZATION,
184: array $sensitiveHeaders = HttpHeaderGroup::SENSITIVE,
185: ?string $mediaType = null,
186: ?string $userAgent = null,
187: bool $expectJson = true,
188: bool $postJson = true,
189: ?DateFormatterInterface $dateFormatter = null,
190: int $formDataFlags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
191: int $jsonDecodeFlags = \JSON_OBJECT_AS_ARRAY,
192: array $middleware = [],
193: ?CurlerPagerInterface $pager = null,
194: bool $alwaysPaginate = false,
195: ?CacheInterface $cacheStore = null,
196: bool $handleCookies = false,
197: ?string $cookiesCacheKey = null,
198: bool $cacheResponses = false,
199: bool $cachePostResponses = false,
200: ?callable $cacheKeyCallback = null,
201: int $cacheLifetime = 3600,
202: bool $refreshCache = false,
203: ?int $timeout = null,
204: bool $followRedirects = false,
205: ?int $maxRedirects = null,
206: bool $retryAfterTooManyRequests = false,
207: int $retryAfterMaxSeconds = 300,
208: bool $throwHttpErrors = true
209: ) {
210: $this->Uri = $this->filterUri($uri);
211: $this->Headers = $this->filterHeaders($headers);
212: $this->AccessToken = $accessToken;
213: if ($accessToken !== null) {
214: $this->AccessTokenHeaderName = $accessTokenHeaderName;
215: }
216: $this->SensitiveHeaders = array_change_key_case(
217: array_fill_keys($sensitiveHeaders, true),
218: );
219: $this->MediaType = $mediaType;
220: $this->UserAgent = $userAgent ?? $this->getDefaultUserAgent();
221: $this->ExpectJson = $expectJson;
222: $this->PostJson = $postJson;
223: $this->DateFormatter = $dateFormatter;
224: $this->FormDataFlags = $formDataFlags;
225: $this->JsonDecodeFlags = $jsonDecodeFlags;
226: foreach ($middleware as $value) {
227: $this->Middleware[] = [$value[0], $value[1] ?? null];
228: }
229: $this->Pager = $pager;
230: $this->AlwaysPaginate = $pager && $alwaysPaginate;
231: $this->CacheStore = $cacheStore;
232: $this->CookiesCacheKey = $handleCookies || $cookiesCacheKey !== null
233: ? $this->filterCookiesCacheKey($cookiesCacheKey)
234: : null;
235: $this->CacheResponses = $cacheResponses;
236: $this->CachePostResponses = $cachePostResponses;
237: $this->CacheKeyClosure = Get::closure($cacheKeyCallback);
238: $this->CacheLifetime = $cacheLifetime;
239: $this->RefreshCache = $refreshCache;
240: $this->Timeout = $timeout;
241: $this->FollowRedirects = $followRedirects;
242: $this->MaxRedirects = $maxRedirects;
243: $this->RetryAfterTooManyRequests = $retryAfterTooManyRequests;
244: $this->RetryAfterMaxSeconds = $retryAfterMaxSeconds;
245: $this->ThrowHttpErrors = $throwHttpErrors;
246: }
247:
248: private function __clone()
249: {
250: $this->LastRequest = null;
251: $this->LastResponse = null;
252: $this->WithoutThrowHttpErrors = null;
253: $this->Closure = null;
254: }
255:
256: /**
257: * @inheritDoc
258: */
259: public function getUri(): UriInterface
260: {
261: return $this->Uri;
262: }
263:
264: /**
265: * @inheritDoc
266: */
267: public function getLastRequest(): ?RequestInterface
268: {
269: return $this->LastRequest;
270: }
271:
272: /**
273: * @inheritDoc
274: */
275: public function getLastResponse(): ?HttpResponseInterface
276: {
277: return $this->LastResponse;
278: }
279:
280: /**
281: * @inheritDoc
282: */
283: public function lastResponseIsJson(): bool
284: {
285: if ($this->LastResponse === null) {
286: throw new OutOfRangeException('No response to check');
287: }
288:
289: return $this->responseIsJson($this->LastResponse);
290: }
291:
292: private function responseIsJson(HttpResponseInterface $response): bool
293: {
294: $headers = $response->getHttpHeaders();
295: if (!$headers->hasHeader(HttpHeader::CONTENT_TYPE)) {
296: return $this->ExpectJson;
297: }
298:
299: try {
300: return Http::mediaTypeIs(
301: $headers->getOneHeaderLine(HttpHeader::CONTENT_TYPE),
302: MimeType::JSON
303: );
304: } catch (InvalidHeaderException $ex) {
305: Console::debug($ex->getMessage());
306: return false;
307: }
308: }
309:
310: // --
311:
312: /**
313: * @inheritDoc
314: */
315: public function head(?array $query = null): HttpHeadersInterface
316: {
317: return $this->process(Method::HEAD, $query);
318: }
319:
320: /**
321: * @inheritDoc
322: */
323: public function get(?array $query = null)
324: {
325: return $this->process(Method::GET, $query);
326: }
327:
328: /**
329: * @inheritDoc
330: */
331: public function post($data = null, ?array $query = null)
332: {
333: return $this->process(Method::POST, $query, $data);
334: }
335:
336: /**
337: * @inheritDoc
338: */
339: public function put($data = null, ?array $query = null)
340: {
341: return $this->process(Method::PUT, $query, $data);
342: }
343:
344: /**
345: * @inheritDoc
346: */
347: public function patch($data = null, ?array $query = null)
348: {
349: return $this->process(Method::PATCH, $query, $data);
350: }
351:
352: /**
353: * @inheritDoc
354: */
355: public function delete($data = null, ?array $query = null)
356: {
357: return $this->process(Method::DELETE, $query, $data);
358: }
359:
360: /**
361: * @template TMethod of Method::GET|Method::HEAD|Method::POST|Method::PUT|Method::PATCH|Method::DELETE
362: *
363: * @param string&TMethod $method
364: * @param mixed[]|null $query
365: * @param mixed[]|object|false|null $data
366: * @return (TMethod is Method::HEAD ? HttpHeadersInterface : mixed)
367: */
368: private function process(string $method, ?array $query, $data = false)
369: {
370: $request = $this->createRequest($method, $query, $data);
371:
372: $pager = $this->AlwaysPaginate ? $this->Pager : null;
373: if ($pager) {
374: $request = $pager->getFirstRequest($request, $this, $query);
375: if ($request instanceof CurlerPageRequestInterface) {
376: $query = $request->getQuery() ?? $query;
377: $request = $request->getRequest();
378: }
379: }
380:
381: $response = $this->doSendRequest($request);
382:
383: if ($method === Method::HEAD) {
384: return $response->getHttpHeaders();
385: }
386:
387: $result = $this->getResponseBody($response);
388:
389: if ($pager) {
390: $page = $pager->getPage($result, $request, $response, $this, null, $query);
391: return Arr::unwrap($page->getEntities(), 1);
392: }
393:
394: return $result;
395: }
396:
397: // --
398:
399: /**
400: * @inheritDoc
401: */
402: public function getP(?array $query = null): iterable
403: {
404: return $this->paginate(Method::GET, $query);
405: }
406:
407: /**
408: * @inheritDoc
409: */
410: public function postP($data = null, ?array $query = null): iterable
411: {
412: return $this->paginate(Method::POST, $query, $data);
413: }
414:
415: /**
416: * @inheritDoc
417: */
418: public function putP($data = null, ?array $query = null): iterable
419: {
420: return $this->paginate(Method::PUT, $query, $data);
421: }
422:
423: /**
424: * @inheritDoc
425: */
426: public function patchP($data = null, ?array $query = null): iterable
427: {
428: return $this->paginate(Method::PATCH, $query, $data);
429: }
430:
431: /**
432: * @inheritDoc
433: */
434: public function deleteP($data = null, ?array $query = null): iterable
435: {
436: return $this->paginate(Method::DELETE, $query, $data);
437: }
438:
439: /**
440: * @param mixed[]|null $query
441: * @param mixed[]|object|false|null $data
442: * @return Generator<mixed>
443: */
444: private function paginate(string $method, ?array $query, $data = false): Generator
445: {
446: if ($this->Pager === null) {
447: throw new LogicException('No pager');
448: }
449:
450: $request = $this->createRequest($method, $query, $data);
451: $request = $this->Pager->getFirstRequest($request, $this, $query);
452: $prev = null;
453: do {
454: if ($request instanceof CurlerPageRequestInterface) {
455: $query = $request->getQuery() ?? $query;
456: $request = $request->getRequest();
457: }
458: $response = $this->doSendRequest($request);
459: $page = $this->Pager->getPage(
460: $this->getResponseBody($response),
461: $request,
462: $response,
463: $this,
464: $prev,
465: $query,
466: );
467: // Use `yield` instead of `yield from` so each entity gets a
468: // unique key
469: foreach ($page->getEntities() as $entity) {
470: yield $entity;
471: }
472: if (!$page->hasNextRequest()) {
473: break;
474: }
475: $request = $page->getNextRequest();
476: $prev = $page;
477: } while (true);
478: }
479:
480: // --
481:
482: /**
483: * @inheritDoc
484: */
485: public function postR(string $data, string $mediaType, ?array $query = null)
486: {
487: return $this->processRaw(Method::POST, $data, $mediaType, $query);
488: }
489:
490: /**
491: * @inheritDoc
492: */
493: public function putR(string $data, string $mediaType, ?array $query = null)
494: {
495: return $this->processRaw(Method::PUT, $data, $mediaType, $query);
496: }
497:
498: /**
499: * @inheritDoc
500: */
501: public function patchR(string $data, string $mediaType, ?array $query = null)
502: {
503: return $this->processRaw(Method::PATCH, $data, $mediaType, $query);
504: }
505:
506: /**
507: * @inheritDoc
508: */
509: public function deleteR(string $data, string $mediaType, ?array $query = null)
510: {
511: return $this->processRaw(Method::DELETE, $data, $mediaType, $query);
512: }
513:
514: /**
515: * @param mixed[]|null $query
516: * @return mixed
517: */
518: private function processRaw(string $method, string $data, string $mediaType, ?array $query)
519: {
520: $request = $this->createRequest($method, $query, $data);
521: $request = $request->withHeader(HttpHeader::CONTENT_TYPE, $mediaType);
522: /** @disregard P1006 */
523: $response = $this->doSendRequest($request);
524: return $this->getResponseBody($response);
525: }
526:
527: // --
528:
529: /**
530: * @inheritDoc
531: */
532: public function flushCookies()
533: {
534: if ($this->CookiesCacheKey !== null) {
535: $this->getCache()->delete($this->CookiesCacheKey);
536: }
537: return $this;
538: }
539:
540: // --
541:
542: /**
543: * @inheritDoc
544: */
545: public function getHttpHeaders(): HttpHeadersInterface
546: {
547: $headers = $this->Headers;
548:
549: if ($this->AccessToken !== null) {
550: $headers = $headers->authorize($this->AccessToken, $this->AccessTokenHeaderName);
551: }
552:
553: if ($this->MediaType !== null) {
554: $headers = $headers->set(HttpHeader::CONTENT_TYPE, $this->MediaType);
555: }
556:
557: if ($this->ExpectJson) {
558: $headers = $headers->set(HttpHeader::ACCEPT, MimeType::JSON);
559: }
560:
561: if ($this->UserAgent !== null) {
562: $headers = $headers->set(HttpHeader::USER_AGENT, $this->UserAgent);
563: }
564:
565: return $headers;
566: }
567:
568: /**
569: * @inheritDoc
570: */
571: public function getPublicHttpHeaders(): HttpHeadersInterface
572: {
573: return $this->getHttpHeaders()->exceptIn($this->SensitiveHeaders);
574: }
575:
576: /**
577: * @inheritDoc
578: */
579: public function hasAccessToken(): bool
580: {
581: return $this->AccessToken !== null;
582: }
583:
584: /**
585: * @inheritDoc
586: */
587: public function isSensitiveHeader(string $name): bool
588: {
589: return isset($this->SensitiveHeaders[Str::lower($name)]);
590: }
591:
592: /**
593: * @inheritDoc
594: */
595: public function getMediaType(): ?string
596: {
597: return $this->MediaType;
598: }
599:
600: /**
601: * @inheritDoc
602: */
603: public function getUserAgent(): string
604: {
605: return $this->UserAgent ?? '';
606: }
607:
608: /**
609: * @inheritDoc
610: */
611: public function expectsJson(): bool
612: {
613: return $this->ExpectJson;
614: }
615:
616: /**
617: * @inheritDoc
618: */
619: public function postsJson(): bool
620: {
621: return $this->PostJson;
622: }
623:
624: /**
625: * @inheritDoc
626: */
627: public function getDateFormatter(): ?DateFormatterInterface
628: {
629: return $this->DateFormatter;
630: }
631:
632: /**
633: * @inheritDoc
634: */
635: public function getFormDataFlags(): int
636: {
637: return $this->FormDataFlags;
638: }
639:
640: /**
641: * @inheritDoc
642: */
643: public function getPager(): ?CurlerPagerInterface
644: {
645: return $this->Pager;
646: }
647:
648: /**
649: * @inheritDoc
650: */
651: public function alwaysPaginates(): bool
652: {
653: return $this->AlwaysPaginate;
654: }
655:
656: /**
657: * @inheritDoc
658: */
659: public function getCacheStore(): ?CacheInterface
660: {
661: return $this->CacheStore;
662: }
663:
664: /**
665: * @inheritDoc
666: */
667: public function hasCookies(): bool
668: {
669: return $this->CookiesCacheKey !== null;
670: }
671:
672: /**
673: * @inheritDoc
674: */
675: public function hasResponseCache(): bool
676: {
677: return $this->CacheResponses;
678: }
679:
680: /**
681: * @inheritDoc
682: */
683: public function hasPostResponseCache(): bool
684: {
685: return $this->CachePostResponses;
686: }
687:
688: /**
689: * @inheritDoc
690: */
691: public function getCacheLifetime(): int
692: {
693: return $this->CacheLifetime;
694: }
695:
696: /**
697: * @inheritDoc
698: */
699: public function refreshesCache(): bool
700: {
701: return $this->RefreshCache;
702: }
703:
704: /**
705: * @inheritDoc
706: */
707: public function getTimeout(): ?int
708: {
709: return $this->Timeout;
710: }
711:
712: /**
713: * @inheritDoc
714: */
715: public function followsRedirects(): bool
716: {
717: return $this->FollowRedirects;
718: }
719:
720: /**
721: * @inheritDoc
722: */
723: public function getMaxRedirects(): ?int
724: {
725: return $this->MaxRedirects;
726: }
727:
728: /**
729: * @inheritDoc
730: */
731: public function getRetryAfterTooManyRequests(): bool
732: {
733: return $this->RetryAfterTooManyRequests;
734: }
735:
736: /**
737: * @inheritDoc
738: */
739: public function getRetryAfterMaxSeconds(): int
740: {
741: return $this->RetryAfterMaxSeconds;
742: }
743:
744: /**
745: * @inheritDoc
746: */
747: public function throwsHttpErrors(): bool
748: {
749: return $this->ThrowHttpErrors;
750: }
751:
752: /**
753: * @inheritDoc
754: */
755: public function replaceQuery($value, array $query)
756: {
757: return Http::replaceQuery(
758: $value,
759: $query,
760: $this->FormDataFlags,
761: $this->DateFormatter,
762: );
763: }
764:
765: // --
766:
767: /**
768: * @inheritDoc
769: */
770: public function withUri($uri)
771: {
772: return (string) $uri === (string) $this->Uri
773: ? $this
774: : $this->with('Uri', $this->filterUri($uri));
775: }
776:
777: /**
778: * @inheritDoc
779: */
780: public function withRequest(RequestInterface $request)
781: {
782: $curler = $this->withUri($request->getUri());
783:
784: $headers = HttpHeaders::from($request);
785: $currentHeaders = $this->getHttpHeaders();
786: if (Arr::same($headers->all(), $currentHeaders->all())) {
787: return $curler;
788: }
789:
790: if ($this->AccessToken !== null) {
791: $header = $this->AccessTokenHeaderName;
792: if ($headers->getHeader($header) !== $currentHeaders->getHeader($header)) {
793: $curler = $curler->withAccessToken(null);
794: }
795: }
796:
797: $mediaType = $headers->getHeaderValues(HttpHeader::CONTENT_TYPE);
798: $userAgent = $headers->getHeaderLine(HttpHeader::USER_AGENT);
799: $expectJson = $headers->getHeader(HttpHeader::ACCEPT) === [MimeType::JSON];
800: return $curler
801: ->withMediaType(count($mediaType) === 1 ? $mediaType[0] : null)
802: ->withUserAgent($userAgent)
803: ->withExpectJson($expectJson)
804: ->with('Headers', $headers);
805: }
806:
807: /**
808: * @inheritDoc
809: */
810: public function withAccessToken(
811: ?AccessTokenInterface $token,
812: string $headerName = HttpHeader::AUTHORIZATION
813: ) {
814: return $token === null
815: ? $this
816: ->with('AccessToken', null)
817: ->without('AccessTokenHeaderName')
818: : $this
819: ->with('AccessToken', $token)
820: ->with('AccessTokenHeaderName', $headerName);
821: }
822:
823: /**
824: * @inheritDoc
825: */
826: public function withSensitiveHeader(string $name)
827: {
828: // @phpstan-ignore salient.property.type
829: return $this->with(
830: 'SensitiveHeaders',
831: Arr::set($this->SensitiveHeaders, Str::lower($name), true)
832: );
833: }
834:
835: /**
836: * @inheritDoc
837: */
838: public function withoutSensitiveHeader(string $name)
839: {
840: // @phpstan-ignore salient.property.type
841: return $this->with(
842: 'SensitiveHeaders',
843: Arr::unset($this->SensitiveHeaders, Str::lower($name))
844: );
845: }
846:
847: /**
848: * @inheritDoc
849: */
850: public function withMediaType(?string $type)
851: {
852: return $this->with('MediaType', $type);
853: }
854:
855: /**
856: * @inheritDoc
857: */
858: public function withUserAgent(?string $userAgent)
859: {
860: // - `null` => default
861: // - `""` => `null`
862: return $this->with(
863: 'UserAgent',
864: Str::coalesce($userAgent ?? $this->getDefaultUserAgent(), null),
865: );
866: }
867:
868: /**
869: * @inheritDoc
870: */
871: public function withExpectJson(bool $expectJson = true)
872: {
873: return $this->with('ExpectJson', $expectJson);
874: }
875:
876: /**
877: * @inheritDoc
878: */
879: public function withPostJson(bool $postJson = true)
880: {
881: return $this->with('PostJson', $postJson);
882: }
883:
884: /**
885: * @inheritDoc
886: */
887: public function withDateFormatter(?DateFormatterInterface $formatter)
888: {
889: return $this->with('DateFormatter', $formatter);
890: }
891:
892: /**
893: * @inheritDoc
894: */
895: public function withFormDataFlags(int $flags)
896: {
897: return $this->with('FormDataFlags', $flags);
898: }
899:
900: /**
901: * @inheritDoc
902: */
903: public function withJsonDecodeFlags(int $flags)
904: {
905: return $this->with('JsonDecodeFlags', $flags);
906: }
907:
908: /**
909: * @inheritDoc
910: */
911: public function withMiddleware($middleware, ?string $name = null)
912: {
913: return $this->with(
914: 'Middleware',
915: Arr::push($this->Middleware, [$middleware, $name])
916: );
917: }
918:
919: /**
920: * @inheritDoc
921: */
922: public function withoutMiddleware($middleware)
923: {
924: $i = is_string($middleware) ? 1 : 0;
925: $values = $this->Middleware;
926: foreach ($values as $key => $value) {
927: if ($middleware === $value[$i]) {
928: unset($values[$key]);
929: }
930: }
931: return $this->with('Middleware', $values);
932: }
933:
934: /**
935: * @inheritDoc
936: */
937: public function withPager(?CurlerPagerInterface $pager, bool $alwaysPaginate = false)
938: {
939: return $this
940: ->with('Pager', $pager)
941: ->with('AlwaysPaginate', $pager && $alwaysPaginate);
942: }
943:
944: /**
945: * @inheritDoc
946: */
947: public function withCacheStore(?CacheInterface $store = null)
948: {
949: return $this->with('CacheStore', $store);
950: }
951:
952: /**
953: * @inheritDoc
954: */
955: public function withCookies(?string $cacheKey = null)
956: {
957: return $this->with('CookiesCacheKey', $this->filterCookiesCacheKey($cacheKey));
958: }
959:
960: /**
961: * @inheritDoc
962: */
963: public function withoutCookies()
964: {
965: return $this->with('CookiesCacheKey', null);
966: }
967:
968: /**
969: * @inheritDoc
970: */
971: public function withResponseCache(bool $cacheResponses = true)
972: {
973: return $this->with('CacheResponses', $cacheResponses);
974: }
975:
976: /**
977: * @inheritDoc
978: */
979: public function withPostResponseCache(bool $cachePostResponses = true)
980: {
981: return $this->with('CachePostResponses', $cachePostResponses);
982: }
983:
984: /**
985: * @inheritDoc
986: */
987: public function withCacheKeyCallback(?callable $callback)
988: {
989: // @phpstan-ignore salient.property.type
990: return $this->with('CacheKeyClosure', Get::closure($callback));
991: }
992:
993: /**
994: * @inheritDoc
995: */
996: public function withCacheLifetime(int $seconds)
997: {
998: return $this->with('CacheLifetime', $seconds);
999: }
1000:
1001: /**
1002: * @inheritDoc
1003: */
1004: public function withRefreshCache(bool $refresh = true)
1005: {
1006: return $this->with('RefreshCache', $refresh);
1007: }
1008:
1009: /**
1010: * @inheritDoc
1011: */
1012: public function withTimeout(?int $seconds)
1013: {
1014: return $this->with('Timeout', $seconds);
1015: }
1016:
1017: /**
1018: * @inheritDoc
1019: */
1020: public function withFollowRedirects(bool $follow = true)
1021: {
1022: return $this->with('FollowRedirects', $follow);
1023: }
1024:
1025: /**
1026: * @inheritDoc
1027: */
1028: public function withMaxRedirects(?int $redirects)
1029: {
1030: return $this->with('MaxRedirects', $redirects);
1031: }
1032:
1033: /**
1034: * @inheritDoc
1035: */
1036: public function withRetryAfterTooManyRequests(bool $retry = true)
1037: {
1038: return $this->with('RetryAfterTooManyRequests', $retry);
1039: }
1040:
1041: /**
1042: * @inheritDoc
1043: */
1044: public function withRetryAfterMaxSeconds(int $seconds)
1045: {
1046: return $this->with('RetryAfterMaxSeconds', $seconds);
1047: }
1048:
1049: /**
1050: * @inheritDoc
1051: */
1052: public function withThrowHttpErrors(bool $throw = true)
1053: {
1054: return $this->with('ThrowHttpErrors', $throw);
1055: }
1056:
1057: // --
1058:
1059: /**
1060: * @inheritDoc
1061: */
1062: public function sendRequest(RequestInterface $request): HttpResponseInterface
1063: {
1064: // PSR-18: "A Client MUST NOT treat a well-formed HTTP request or HTTP
1065: // response as an error condition. For example, response status codes in
1066: // the 400 and 500 range MUST NOT cause an exception and MUST be
1067: // returned to the Calling Library as normal."
1068: $curler = $this->WithoutThrowHttpErrors
1069: ??= $this->withThrowHttpErrors(false);
1070: try {
1071: try {
1072: return $curler->doSendRequest($request);
1073: } finally {
1074: $this->LastRequest = $curler->LastRequest;
1075: $this->LastResponse = $curler->LastResponse;
1076: }
1077: } catch (ClientExceptionInterface $ex) {
1078: throw $ex;
1079: } catch (CurlErrorExceptionInterface $ex) {
1080: throw $ex->isNetworkError()
1081: ? new NetworkException($ex->getMessage(), $this->LastRequest ?? $request, [], $ex)
1082: : new RequestException($ex->getMessage(), $this->LastRequest ?? $request, [], $ex);
1083: } catch (Throwable $ex) {
1084: throw new RequestException($ex->getMessage(), $this->LastRequest ?? $request, [], $ex);
1085: }
1086: }
1087:
1088: /**
1089: * @phpstan-assert !null $this->LastRequest
1090: * @phpstan-assert !null $this->LastResponse
1091: */
1092: private function doSendRequest(RequestInterface $request): HttpResponseInterface
1093: {
1094: $this->LastRequest = null;
1095: $this->LastResponse = null;
1096:
1097: return $this->Middleware
1098: ? ($this->Closure ??= $this->getClosure())($request)
1099: : $this->getResponse($request);
1100: }
1101:
1102: /**
1103: * @return Closure(RequestInterface): HttpResponseInterface
1104: */
1105: private function getClosure(): Closure
1106: {
1107: $closure = fn(RequestInterface $request): HttpResponseInterface =>
1108: $this->getResponse($request);
1109:
1110: foreach (array_reverse($this->Middleware) as [$middleware]) {
1111: $closure = $middleware instanceof CurlerMiddlewareInterface
1112: ? fn(RequestInterface $request): HttpResponseInterface =>
1113: $this->handleHttpResponse($middleware($request, $closure, $this), $request)
1114: : ($middleware instanceof HttpRequestHandlerInterface
1115: ? fn(RequestInterface $request): HttpResponseInterface =>
1116: $this->handleResponse($middleware($request, $closure), $request)
1117: : fn(RequestInterface $request): HttpResponseInterface =>
1118: $this->handleResponse($middleware($request, $closure, $this), $request));
1119: }
1120:
1121: return $closure;
1122: }
1123:
1124: private function getResponse(RequestInterface $request): HttpResponseInterface
1125: {
1126: $uri = $request->getUri()->withFragment('');
1127: $request = $request->withUri($uri);
1128:
1129: $version = (int) ((float) $request->getProtocolVersion() * 10);
1130: $opt[\CURLOPT_HTTP_VERSION] = [
1131: 10 => \CURL_HTTP_VERSION_1_0,
1132: 11 => \CURL_HTTP_VERSION_1_1,
1133: 20 => \CURL_HTTP_VERSION_2_0,
1134: ][$version] ?? \CURL_HTTP_VERSION_NONE;
1135:
1136: $method = $request->getMethod();
1137: $body = $request->getBody();
1138: $opt[\CURLOPT_CUSTOMREQUEST] = $method;
1139: if ($method === Method::HEAD) {
1140: $opt[\CURLOPT_NOBODY] = true;
1141: $size = 0;
1142: } else {
1143: $size = $body->getSize();
1144: }
1145:
1146: if ($size === null || $size > 0) {
1147: $size ??= HttpHeaders::from($request)->getContentLength();
1148: if ($size !== null && $size <= static::MAX_INPUT_LENGTH) {
1149: $body = (string) $body;
1150: $opt[\CURLOPT_POSTFIELDS] = $body;
1151: $request = $request
1152: ->withoutHeader(HttpHeader::CONTENT_LENGTH)
1153: ->withoutHeader(HttpHeader::TRANSFER_ENCODING);
1154: } else {
1155: $opt[\CURLOPT_UPLOAD] = true;
1156: if ($size !== null) {
1157: $opt[\CURLOPT_INFILESIZE] = $size;
1158: $request = $request
1159: ->withoutHeader(HttpHeader::CONTENT_LENGTH);
1160: }
1161: $opt[\CURLOPT_READFUNCTION] =
1162: static function ($handle, $infile, int $length) use ($body): string {
1163: return $body->read($length);
1164: };
1165: if ($body->isSeekable()) {
1166: $body->rewind();
1167: }
1168: }
1169: } elseif (self::METHOD_HAS_BODY[$method] ?? false) {
1170: // [RFC7230], Section 3.3.2: "A user agent SHOULD send a
1171: // Content-Length in a request message when no Transfer-Encoding is
1172: // sent and the request method defines a meaning for an enclosed
1173: // payload body. For example, a Content-Length header field is
1174: // normally sent in a POST request even when the value is 0
1175: // (indicating an empty payload body)."
1176: $request = $request->withHeader(HttpHeader::CONTENT_LENGTH, '0');
1177: }
1178:
1179: if ($this->Timeout !== null) {
1180: $opt[\CURLOPT_CONNECTTIMEOUT] = $this->Timeout;
1181: }
1182:
1183: if (!$request->hasHeader(HttpHeader::ACCEPT_ENCODING)) {
1184: // Enable all supported encodings (e.g. gzip, deflate) and set
1185: // `Accept-Encoding` accordingly
1186: $opt[\CURLOPT_ENCODING] = '';
1187: }
1188:
1189: /** @var string|null */
1190: $statusLine = null;
1191: /** @var HttpHeaders|null */
1192: $headersIn = null;
1193: $opt[\CURLOPT_HEADERFUNCTION] =
1194: static function ($handle, string $header) use (&$statusLine, &$headersIn): int {
1195: if (substr($header, 0, 5) === 'HTTP/') {
1196: $statusLine = rtrim($header, "\r\n");
1197: $headersIn = new HttpHeaders();
1198: return strlen($header);
1199: }
1200: if ($headersIn === null) {
1201: throw new InvalidHeaderException('No status line in HTTP response');
1202: }
1203: $headersIn = $headersIn->addLine($header);
1204: return strlen($header);
1205: };
1206:
1207: /** @var HttpStream|null */
1208: $bodyIn = null;
1209: $opt[\CURLOPT_WRITEFUNCTION] =
1210: static function ($handle, string $data) use (&$bodyIn): int {
1211: /** @var HttpStream $bodyIn */
1212: return $bodyIn->write($data);
1213: };
1214:
1215: if (self::$Handle === null) {
1216: $handle = curl_init((string) $uri);
1217: if ($handle === false) {
1218: throw new RuntimeException('curl_init() failed');
1219: }
1220: self::$Handle = $handle;
1221: $resetHandle = false;
1222: } else {
1223: $opt[\CURLOPT_URL] = (string) $uri;
1224: $resetHandle = true;
1225: }
1226:
1227: $request = $this->normaliseRequest($request, $uri);
1228: $headers = HttpHeaders::from($request);
1229:
1230: $cacheKey = null;
1231: $transfer = 0;
1232: $redirects = $this->FollowRedirects && $this->MaxRedirects !== 0
1233: ? $this->MaxRedirects ?? 30
1234: : false;
1235: $retrying = false;
1236: do {
1237: if ($cacheKey === null) {
1238: if (
1239: $this->CacheResponses
1240: && ($size === 0 || is_string($body))
1241: && ([
1242: Method::GET => true,
1243: Method::HEAD => true,
1244: Method::POST => $this->CachePostResponses,
1245: ][$method] ?? false)
1246: ) {
1247: $cacheKey = $this->CacheKeyClosure
1248: ? (array) ($this->CacheKeyClosure)($request, $this)
1249: : $headers->exceptIn($this->getUnstableHeaders())->getLines('%s:%s');
1250:
1251: if ($size !== 0 || $method === Method::POST) {
1252: $cacheKey[] = $size === 0 ? '' : $body;
1253: }
1254:
1255: $cacheUri = $uri->getPath() === ''
1256: ? $uri->withPath('/')
1257: : $uri;
1258: $cacheKey = implode(':', [
1259: self::class,
1260: 'response',
1261: $method,
1262: rawurlencode((string) $cacheUri),
1263: Get::hash(implode("\0", $cacheKey)),
1264: ]);
1265: } else {
1266: $cacheKey = false;
1267: }
1268: }
1269:
1270: if (
1271: $cacheKey !== false
1272: && !$this->RefreshCache
1273: && ($last = $this->getCache()->getArray($cacheKey)) !== null
1274: ) {
1275: /** @var array{code:int,body:string,headers:array<array{name:string,value:string}>|HttpHeaders,reason:string|null,version:string}|array{int,string,HttpHeaders,string} $last */
1276: $code = $last['code'] ?? $last[0] ?? 200;
1277: $bodyIn = HttpStream::fromString($last['body'] ?? $last[3] ?? '');
1278: $lastHeaders = $last['headers'] ?? $last[2] ?? null;
1279: if (is_array($lastHeaders)) {
1280: $lastHeaders = Http::getNameValueGenerator($lastHeaders);
1281: }
1282: $headersIn = HttpHeaders::from($lastHeaders ?? []);
1283: $reason = $last['reason'] ?? $last[1] ?? null;
1284: $version = $last['version'] ?? '1.1';
1285: $response = new HttpResponse($code, $bodyIn, $headersIn, $reason, $version);
1286: Event::dispatch(new ResponseCacheHitEvent($this, $request, $response));
1287: } else {
1288: if ($transfer) {
1289: if ($size !== 0 && $body instanceof StreamInterface) {
1290: if (!$body->isSeekable()) {
1291: throw new RequestException(
1292: 'Request cannot be sent again (body not seekable)',
1293: $request,
1294: );
1295: }
1296: $body->rewind();
1297: }
1298: $statusLine = null;
1299: $headersIn = null;
1300: }
1301: $bodyIn = new HttpStream(File::open('php://temp', 'r+'));
1302:
1303: if ($resetHandle || !$transfer) {
1304: if ($resetHandle) {
1305: curl_reset(self::$Handle);
1306: $resetHandle = false;
1307: }
1308: $opt[\CURLOPT_HTTPHEADER] = $headers->getLines('%s: %s', '%s;');
1309: curl_setopt_array(self::$Handle, $opt);
1310:
1311: if ($this->CookiesCacheKey !== null) {
1312: // "If the name is an empty string, no cookies are loaded,
1313: // but cookie handling is still enabled"
1314: curl_setopt(self::$Handle, \CURLOPT_COOKIEFILE, '');
1315: $cookies = $this->getCache()->getArray($this->CookiesCacheKey);
1316: if ($cookies) {
1317: foreach ($cookies as $cookie) {
1318: curl_setopt(self::$Handle, \CURLOPT_COOKIELIST, $cookie);
1319: }
1320: }
1321: }
1322: }
1323:
1324: $transfer++;
1325:
1326: Event::dispatch(new CurlRequestEvent($this, self::$Handle, $request));
1327: $result = curl_exec(self::$Handle);
1328: if ($result === false) {
1329: throw new CurlErrorException(curl_errno(self::$Handle), $request, $this->getCurlInfo());
1330: }
1331:
1332: if (
1333: $statusLine === null
1334: || count($split = explode(' ', $statusLine, 3)) < 2
1335: || ($version = explode('/', $split[0])[1] ?? null) === null
1336: ) {
1337: // @codeCoverageIgnoreStart
1338: throw new InvalidHeaderException(sprintf(
1339: 'HTTP status line invalid or not in response: %s',
1340: rtrim((string) $statusLine, "\r\n"),
1341: ));
1342: // @codeCoverageIgnoreEnd
1343: }
1344:
1345: /** @var HttpHeaders $headersIn */
1346: $code = (int) $split[1];
1347: $reason = $split[2] ?? null;
1348: $response = new HttpResponse($code, $bodyIn, $headersIn, $reason, $version);
1349: Event::dispatch(new CurlResponseEvent($this, self::$Handle, $request, $response));
1350:
1351: if ($this->CookiesCacheKey !== null) {
1352: $this->getCache()->set(
1353: $this->CookiesCacheKey,
1354: curl_getinfo(self::$Handle, \CURLINFO_COOKIELIST)
1355: );
1356: }
1357:
1358: if ($cacheKey !== false && $this->CacheLifetime >= 0 && $code < 400) {
1359: $ttl = $this->CacheLifetime === 0
1360: ? null
1361: : $this->CacheLifetime;
1362: $this->getCache()->set($cacheKey, [
1363: 'code' => $code,
1364: 'body' => (string) $bodyIn,
1365: 'headers' => $headersIn->jsonSerialize(),
1366: 'reason' => $reason,
1367: 'version' => $version,
1368: ], $ttl);
1369: }
1370: }
1371:
1372: if (
1373: $redirects !== false
1374: && $code >= 300
1375: && $code < 400
1376: && count($location = $headersIn->getHeader(HttpHeader::LOCATION)) === 1
1377: && ($location = $location[0]) !== ''
1378: ) {
1379: if (!$redirects) {
1380: throw new TooManyRedirectsException(sprintf(
1381: 'Redirect limit exceeded: %d',
1382: $this->MaxRedirects,
1383: ), $request, $response);
1384: }
1385: $uri = Uri::from($uri)->follow($location)->withFragment('');
1386: $request = $request->withUri($uri);
1387: // Match cURL's behaviour
1388: if (($code === 301 || $code === 302 || $code === 303) && (
1389: $size !== 0
1390: || (self::METHOD_HAS_BODY[$method] ?? false)
1391: )) {
1392: $method = Method::GET;
1393: $body = HttpStream::fromString('');
1394: $request = $request
1395: ->withMethod($method)
1396: ->withBody($body)
1397: ->withoutHeader(HttpHeader::CONTENT_LENGTH)
1398: ->withoutHeader(HttpHeader::TRANSFER_ENCODING);
1399: $size = 0;
1400: $opt[\CURLOPT_CUSTOMREQUEST] = $method;
1401: $opt[\CURLOPT_URL] = (string) $uri;
1402: unset(
1403: $opt[\CURLOPT_POSTFIELDS],
1404: $opt[\CURLOPT_UPLOAD],
1405: $opt[\CURLOPT_INFILESIZE],
1406: $opt[\CURLOPT_READFUNCTION],
1407: );
1408: $resetHandle = true;
1409: } else {
1410: curl_setopt(
1411: self::$Handle,
1412: \CURLOPT_URL,
1413: $opt[\CURLOPT_URL] = (string) $uri,
1414: );
1415: }
1416: $request = $this->normaliseRequest($request, $uri);
1417: $headers = HttpHeaders::from($request);
1418: $cacheKey = null;
1419: $redirects--;
1420: $retrying = false;
1421: continue;
1422: }
1423:
1424: if (
1425: !$this->RetryAfterTooManyRequests
1426: || $retrying
1427: || $code !== 429
1428: || ($after = $headersIn->getRetryAfter()) === null
1429: || ($this->RetryAfterMaxSeconds !== 0 && $after > $this->RetryAfterMaxSeconds)
1430: ) {
1431: break;
1432: }
1433:
1434: $after = max(1, $after);
1435: Console::debug(Inflect::format($after, 'Sleeping for {{#}} {{#:second}}'));
1436: sleep($after);
1437: $retrying = true;
1438: } while (true);
1439:
1440: $this->LastRequest = $request;
1441: $this->LastResponse = $response;
1442:
1443: if ($this->ThrowHttpErrors && $code >= 400) {
1444: throw new HttpErrorException($request, $response, $this->getCurlInfo());
1445: }
1446:
1447: return $response;
1448: }
1449:
1450: private function normaliseRequest(
1451: RequestInterface $request,
1452: PsrUriInterface $uri
1453: ): RequestInterface {
1454: // Remove `Host` if its value is redundant
1455: if (($host = $request->getHeaderLine(HttpHeader::HOST)) !== '') {
1456: try {
1457: $host = new Uri("//$host");
1458: } catch (InvalidArgumentException $ex) {
1459: throw new InvalidHeaderException(sprintf(
1460: 'Invalid value for HTTP request header %s: %s',
1461: HttpHeader::HOST,
1462: $host,
1463: ), $ex);
1464: }
1465: $host = $host->withScheme($uri->getScheme())->getAuthority();
1466: if ($host === $uri->withUserInfo('')->getAuthority()) {
1467: $request = $request->withoutHeader(HttpHeader::HOST);
1468: }
1469: }
1470:
1471: return $request;
1472: }
1473:
1474: // --
1475:
1476: /**
1477: * @param mixed[]|null $query
1478: * @param mixed[]|object|string|false|null $data
1479: */
1480: private function createRequest(string $method, ?array $query, $data): HttpRequest
1481: {
1482: $uri = $this->Uri;
1483: if ($query) {
1484: $uri = $this->replaceQuery($uri, $query);
1485: }
1486: $headers = $this->getHttpHeaders();
1487: $request = new HttpRequest($method, $uri, null, $headers);
1488: if ($data !== false) {
1489: return $this->applyData($request, $data);
1490: }
1491: return $request;
1492: }
1493:
1494: /**
1495: * @param mixed[]|object|string|null $data
1496: */
1497: private function applyData(HttpRequest $request, $data): HttpRequest
1498: {
1499: if ($data === null) {
1500: if ($this->PostJson) {
1501: Console::debug(sprintf(
1502: 'JSON message bodies cannot be empty; falling back to %s',
1503: MimeType::FORM,
1504: ));
1505: }
1506: return $request->withHeader(HttpHeader::CONTENT_TYPE, MimeType::FORM);
1507: }
1508: if (is_string($data)) {
1509: return $request->withBody(HttpStream::fromString($data));
1510: }
1511: if ($this->PostJson) {
1512: try {
1513: $body = HttpStream::fromData($data, $this->FormDataFlags, $this->DateFormatter, true);
1514: $mediaType = MimeType::JSON;
1515: } catch (StreamEncapsulationException $ex) {
1516: Console::debug(sprintf(
1517: '%s; falling back to %s',
1518: $ex->getMessage(),
1519: MimeType::FORM_MULTIPART,
1520: ));
1521: }
1522: }
1523: $body ??= HttpStream::fromData($data, $this->FormDataFlags, $this->DateFormatter);
1524: $mediaType ??= $body instanceof HttpMultipartStreamInterface
1525: ? MimeType::FORM_MULTIPART
1526: : MimeType::FORM;
1527: return $request
1528: ->withHeader(HttpHeader::CONTENT_TYPE, $mediaType)
1529: ->withBody($body);
1530: }
1531:
1532: /**
1533: * @return mixed
1534: */
1535: private function getResponseBody(HttpResponseInterface $response)
1536: {
1537: $body = (string) $response->getBody();
1538: return $this->responseIsJson($response)
1539: ? ($body === ''
1540: ? null
1541: : Json::parse($body, $this->JsonDecodeFlags))
1542: : $body;
1543: }
1544:
1545: private function handleResponse(
1546: ResponseInterface $response,
1547: RequestInterface $request
1548: ): HttpResponseInterface {
1549: return $this->handleHttpResponse(
1550: $response instanceof HttpResponseInterface
1551: ? $response
1552: : HttpResponse::fromPsr7($response),
1553: $request,
1554: );
1555: }
1556:
1557: private function handleHttpResponse(
1558: HttpResponseInterface $response,
1559: RequestInterface $request
1560: ): HttpResponseInterface {
1561: $this->LastRequest = $request;
1562: $this->LastResponse = $response;
1563: return $response;
1564: }
1565:
1566: /**
1567: * @param PsrUriInterface|Stringable|string|null $uri
1568: */
1569: private function filterUri($uri): Uri
1570: {
1571: if ($uri === null) {
1572: return new Uri();
1573: }
1574: $uri = Uri::from($uri);
1575: $invalid = array_intersect(['query', 'fragment'], array_keys($uri->toParts()));
1576: if ($invalid) {
1577: throw new InvalidArgumentException(Inflect::format(
1578: $invalid,
1579: 'URI cannot have %s {{#:component}}',
1580: implode(' or ', $invalid),
1581: ));
1582: }
1583: return $uri;
1584: }
1585:
1586: /**
1587: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|null $headers
1588: */
1589: private function filterHeaders($headers): HttpHeadersInterface
1590: {
1591: if ($headers instanceof HttpHeadersInterface) {
1592: return $headers;
1593: }
1594: return new HttpHeaders($headers ?? []);
1595: }
1596:
1597: private function filterCookiesCacheKey(?string $cacheKey): string
1598: {
1599: return Arr::implode(':', [self::class, 'cookies', $cacheKey], '');
1600: }
1601:
1602: private function getCache(): CacheInterface
1603: {
1604: return $this->CacheStore ?? Cache::getInstance();
1605: }
1606:
1607: private function getDefaultUserAgent(): string
1608: {
1609: return self::$DefaultUserAgent ??= Http::getProduct();
1610: }
1611:
1612: /**
1613: * @return array<string,true>
1614: */
1615: private function getUnstableHeaders(): array
1616: {
1617: return self::$UnstableHeaders
1618: ??= array_fill_keys(HttpHeaderGroup::UNSTABLE, true);
1619: }
1620:
1621: /**
1622: * @return array<string,mixed>
1623: */
1624: private function getCurlInfo(): array
1625: {
1626: if (
1627: self::$Handle === null
1628: || ($info = curl_getinfo(self::$Handle)) === false
1629: ) {
1630: return [];
1631: }
1632:
1633: foreach ($info as &$value) {
1634: if (is_string($value)) {
1635: $value = trim($value);
1636: }
1637: }
1638:
1639: return $info;
1640: }
1641: }
1642: