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