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