1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\MessageInterface;
6: use Salient\Collection\ReadOnlyArrayAccessTrait;
7: use Salient\Collection\ReadOnlyCollectionTrait;
8: use Salient\Contract\Collection\CollectionInterface;
9: use Salient\Contract\Core\Arrayable;
10: use Salient\Contract\Http\AccessTokenInterface;
11: use Salient\Contract\Http\HttpHeader;
12: use Salient\Contract\Http\HttpHeadersInterface;
13: use Salient\Contract\Http\HttpMessageInterface;
14: use Salient\Core\Concern\ImmutableTrait;
15: use Salient\Http\Exception\InvalidHeaderException;
16: use Salient\Utility\Arr;
17: use Salient\Utility\Regex;
18: use Salient\Utility\Str;
19: use Salient\Utility\Test;
20: use InvalidArgumentException;
21: use LogicException;
22:
23: /**
24: * An [RFC7230]-compliant HTTP header collection
25: *
26: * Headers can be applied explicitly or by passing HTTP header fields to
27: * {@see HttpHeaders::addLine()}.
28: *
29: * @api
30: */
31: class HttpHeaders implements HttpHeadersInterface
32: {
33: /** @use ReadOnlyCollectionTrait<string,string[]> */
34: use ReadOnlyCollectionTrait;
35: /** @use ReadOnlyArrayAccessTrait<string,string[]> */
36: use ReadOnlyArrayAccessTrait;
37: use ImmutableTrait;
38:
39: private const HTTP_HEADER_FIELD_NAME = '/^[-0-9a-z!#$%&\'*+.^_`|~]++$/iD';
40: private const HTTP_HEADER_FIELD_VALUE = '/^([\x21-\x7e\x80-\xff]++(?:\h++[\x21-\x7e\x80-\xff]++)*+)?$/D';
41:
42: private const HTTP_HEADER_FIELD = <<<'REGEX'
43: / ^
44: (?(DEFINE)
45: (?<token> [-0-9a-z!#$%&'*+.^_`|~]++ )
46: (?<field_vchar> [\x21-\x7e\x80-\xff]++ )
47: (?<field_content> (?&field_vchar) (?: \h++ (?&field_vchar) )*+ )
48: )
49: (?:
50: (?<name> (?&token) ) (?<bad_whitespace> \h++ )?+ : \h*+ (?<value> (?&field_content)? ) |
51: \h++ (?<extended> (?&field_content)? )
52: )
53: (?<carry> \h++ )?
54: $ /xiD
55: REGEX;
56:
57: /**
58: * [ [ Name => value ], ... ]
59: *
60: * @var array<int,non-empty-array<string,string>>
61: */
62: protected array $Headers = [];
63:
64: /**
65: * [ Lowercase name => [ index in $Headers, ... ] ]
66: *
67: * @var array<string,int[]>
68: */
69: protected array $Index = [];
70:
71: /**
72: * [ Index in $Headers => true ]
73: *
74: * @var array<int,true>
75: */
76: protected array $Trailers = [];
77:
78: protected bool $Closed = false;
79:
80: /**
81: * Trailing whitespace carried over from the previous line
82: *
83: * Applied before `obs-fold` when a header is extended over multiple lines.
84: */
85: protected ?string $Carry = null;
86:
87: /**
88: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string> $items
89: */
90: public function __construct($items = [])
91: {
92: $this->Items = $this->filterByLower($this->getItemsArray($items, $headers, $index));
93: $this->Index = $this->filterByLower($index);
94: $this->Headers = $headers;
95: }
96:
97: /**
98: * Resolve a value to an HttpHeaders object
99: *
100: * If `$value` is a string, it is parsed as an HTTP message.
101: *
102: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|MessageInterface|string $value
103: * @return static
104: */
105: public static function from($value): self
106: {
107: if ($value instanceof static) {
108: return $value;
109: }
110: if ($value instanceof HttpMessageInterface) {
111: return self::from($value->getHttpHeaders());
112: }
113: if ($value instanceof MessageInterface) {
114: return new static($value->getHeaders());
115: }
116: if (is_string($value)) {
117: $lines =
118: // Remove start line
119: Arr::shift(
120: // Split on CRLF
121: explode(
122: "\r\n",
123: // Remove body if present
124: explode("\r\n\r\n", Str::setEol($value, "\r\n"), 2)[0] . "\r\n"
125: )
126: );
127: $instance = new static();
128: foreach ($lines as $line) {
129: $instance = $instance->addLine("$line\r\n");
130: }
131: return $instance;
132: }
133: return new static($value);
134: }
135:
136: /**
137: * Get the value of the Content-Length header, or null if it is not set
138: *
139: * @return int<0,max>|null
140: * @throws InvalidHeaderException if `Content-Length` is given multiple
141: * times or has an invalid value.
142: */
143: public function getContentLength(): ?int
144: {
145: if (!$this->hasHeader(HttpHeader::CONTENT_LENGTH)) {
146: return null;
147: }
148:
149: $length = $this->getOneHeaderLine(HttpHeader::CONTENT_LENGTH);
150: if (!Test::isInteger($length) || (int) $length < 0) {
151: throw new InvalidHeaderException(sprintf(
152: 'Invalid value for HTTP header %s: %s',
153: HttpHeader::CONTENT_LENGTH,
154: $length,
155: ));
156: }
157:
158: return (int) $length;
159: }
160:
161: /**
162: * Get the value of the Content-Type header's boundary parameter, or null if
163: * it is not set
164: *
165: * @throws InvalidHeaderException if `Content-Type` is given multiple times
166: * or has an invalid value.
167: */
168: public function getMultipartBoundary(): ?string
169: {
170: if (!$this->hasHeader(HttpHeader::CONTENT_TYPE)) {
171: return null;
172: }
173:
174: try {
175: return HttpUtil::getParameters(
176: $this->getOneHeaderLine(HttpHeader::CONTENT_TYPE),
177: false,
178: false,
179: )['boundary'] ?? null;
180: } catch (InvalidArgumentException $ex) {
181: throw new InvalidHeaderException($ex->getMessage());
182: }
183: }
184:
185: /**
186: * Get preferences applied to the Prefer header as per [RFC7240]
187: *
188: * @return array<string,array{value:string,parameters:array<string,string>}>
189: */
190: public function getPreferences(): array
191: {
192: if (!$this->hasHeader(HttpHeader::PREFER)) {
193: return [];
194: }
195:
196: foreach ($this->getHeaderValues(HttpHeader::PREFER) as $pref) {
197: /** @var array<string,string> */
198: $params = HttpUtil::getParameters($pref, true);
199: if (!$params) {
200: continue;
201: }
202: $value = reset($params);
203: $name = key($params);
204: unset($params[$name]);
205: $prefs[$name] ??= ['value' => $value, 'parameters' => $params];
206: }
207:
208: return $prefs ?? [];
209: }
210:
211: /**
212: * Merge preferences into a Prefer header value as per [RFC7240]
213: *
214: * @param array<string,array{value:string,parameters?:array<string,string>}|string> $preferences
215: */
216: public static function mergePreferences(array $preferences): string
217: {
218: foreach ($preferences as $name => $pref) {
219: $lower = Str::lower($name);
220: if (isset($prefs[$lower])) {
221: continue;
222: }
223: $prefs[$lower] = HttpUtil::mergeParameters(
224: is_string($pref)
225: ? [$name => $pref]
226: : [$name => $pref['value']] + ($pref['parameters'] ?? [])
227: );
228: }
229:
230: return implode(', ', $prefs ?? []);
231: }
232:
233: /**
234: * Get the value of the Retry-After header in seconds, or null if it has an
235: * invalid value or is not set
236: *
237: * @return int<0,max>|null
238: */
239: public function getRetryAfter(): ?int
240: {
241: $after = $this->getHeaderLine(HttpHeader::RETRY_AFTER);
242: if (Test::isInteger($after) && (int) $after >= 0) {
243: return (int) $after;
244: }
245:
246: $after = strtotime($after);
247: if ($after === false) {
248: return null;
249: }
250:
251: return max(0, $after - time());
252: }
253:
254: /**
255: * @inheritDoc
256: */
257: public function __toString(): string
258: {
259: return implode("\r\n", $this->getLines());
260: }
261:
262: /**
263: * @inheritDoc
264: */
265: public function addLine(string $line, bool $strict = false)
266: {
267: if ($strict && substr($line, -2) !== "\r\n") {
268: throw new InvalidArgumentException('HTTP header field must end with CRLF');
269: }
270:
271: if ($line === "\r\n" || (!$strict && trim($line) === '')) {
272: if ($strict && $this->Closed) {
273: throw new InvalidArgumentException('HTTP message cannot have empty header after body');
274: }
275: return $this->with('Closed', true)->with('Carry', null);
276: }
277:
278: $extend = false;
279: $name = null;
280: $value = null;
281: if ($strict) {
282: $line = substr($line, 0, -2);
283: if (
284: !Regex::match(self::HTTP_HEADER_FIELD, $line, $matches, \PREG_UNMATCHED_AS_NULL)
285: || $matches['bad_whitespace'] !== null
286: ) {
287: throw new InvalidArgumentException(sprintf('Invalid HTTP header field: %s', $line));
288: }
289: // Section 3.2.4 of [RFC7230]: "A user agent that receives an
290: // obs-fold in a response message that is not within a message/http
291: // container MUST replace each received obs-fold with one or more SP
292: // octets prior to interpreting the field value."
293: $carry = $matches['carry'];
294: if ($matches['extended'] !== null) {
295: if (!$this->Headers) {
296: throw new InvalidArgumentException(sprintf('Invalid line folding: %s', $line));
297: }
298: $extend = true;
299: $line = $this->Carry . ' ' . $matches['extended'];
300: } else {
301: $name = $matches['name'];
302: $value = $matches['value'];
303: }
304: } else {
305: $line = rtrim($line, "\r\n");
306: $carry = Regex::match('/\h+$/', $line, $matches) ? $matches[0] : null;
307: if (strpos(" \t", $line[0]) !== false) {
308: $extend = true;
309: $line = $this->Carry . ' ' . trim($line);
310: } else {
311: $split = explode(':', $line, 2);
312: if (count($split) !== 2) {
313: return $this->with('Carry', null);
314: }
315: // Whitespace after name is not allowed since [RFC7230] (see
316: // Section 3.2.4) and should be removed from upstream responses
317: $name = rtrim($split[0]);
318: $value = trim($split[1]);
319: }
320: }
321:
322: if ($extend) {
323: return $this->extendLast($line)->with('Carry', $carry);
324: }
325:
326: /** @var string $name */
327: /** @var string $value */
328: return $this->append($name, $value)->maybeIndexTrailer()->with('Carry', $carry);
329: }
330:
331: /**
332: * @inheritDoc
333: */
334: public function hasLastLine(): bool
335: {
336: return $this->Closed;
337: }
338:
339: /**
340: * @inheritDoc
341: */
342: public function append($key, $value)
343: {
344: $values = (array) $value;
345: if (!$values) {
346: throw new InvalidArgumentException(
347: sprintf('At least one value must be given for HTTP header: %s', $key)
348: );
349: }
350: $lower = Str::lower($key);
351: $headers = $this->Headers;
352: $index = $this->Index;
353: $key = $this->filterName($key);
354: foreach ($values as $value) {
355: $headers[] = [$key => $this->filterValue($value)];
356: $index[$lower][] = array_key_last($headers);
357: }
358: return $this->replaceHeaders($headers, $index);
359: }
360:
361: /**
362: * @inheritDoc
363: */
364: public function set($key, $value)
365: {
366: $values = (array) $value;
367: if (!$values) {
368: throw new InvalidArgumentException(
369: sprintf('At least one value must be given for HTTP header: %s', $key)
370: );
371: }
372: $lower = Str::lower($key);
373: $headers = $this->Headers;
374: $index = $this->Index;
375: if (isset($index[$lower])) {
376: // Return `$this` if existing values are being reapplied
377: if (count($index[$lower]) === count($values)) {
378: $headerIndex = $index[$lower];
379: $changed = false;
380: foreach ($values as $value) {
381: $i = array_shift($headerIndex);
382: if ($headers[$i] !== [$key => $value]) {
383: $changed = true;
384: break;
385: }
386: }
387: if (!$changed) {
388: return $this;
389: }
390: }
391: foreach ($index[$lower] as $i) {
392: unset($headers[$i]);
393: }
394: unset($index[$lower]);
395: }
396: $key = $this->filterName($key);
397: foreach ($values as $value) {
398: $headers[] = [$key => $this->filterValue($value)];
399: $index[$lower][] = array_key_last($headers);
400: }
401: return $this->replaceHeaders($headers, $index);
402: }
403:
404: /**
405: * @inheritDoc
406: */
407: public function unset($key)
408: {
409: $lower = Str::lower($key);
410: if (!isset($this->Index[$lower])) {
411: return $this;
412: }
413: $headers = $this->Headers;
414: $index = $this->Index;
415: foreach ($index[$lower] as $i) {
416: unset($headers[$i]);
417: }
418: unset($index[$lower]);
419: return $this->replaceHeaders($headers, $index);
420: }
421:
422: /**
423: * @inheritDoc
424: */
425: public function add($value)
426: {
427: throw new LogicException('HTTP header required');
428: }
429:
430: /**
431: * @inheritDoc
432: */
433: public function merge($items, bool $addToExisting = false)
434: {
435: $headers = $this->Headers;
436: $index = $this->Index;
437: $applied = false;
438: foreach ($this->getItems($items) as $key => $value) {
439: $values = (array) $value;
440: $lower = Str::lower($key);
441: if (
442: !$addToExisting
443: // Checking against $this->Index instead of $index means any
444: // duplicates in $items will be preserved
445: && isset($this->Index[$lower])
446: && !($unset[$lower] ?? false)
447: ) {
448: $unset[$lower] = true;
449: foreach ($index[$lower] as $i) {
450: unset($headers[$i]);
451: }
452: // Maintain the order of $index for comparison
453: $index[$lower] = [];
454: }
455: foreach ($values as $value) {
456: $applied = true;
457: $headers[] = [$key => $value];
458: $index[$lower][] = array_key_last($headers);
459: }
460: }
461:
462: if (
463: ($addToExisting && !$applied)
464: || $this->getIndexValues($headers, $index) === $this->getIndexValues($this->Headers, $this->Index)
465: ) {
466: return $this;
467: }
468:
469: return $this->replaceHeaders($headers, $index);
470: }
471:
472: /**
473: * @inheritDoc
474: */
475: public function authorize(
476: AccessTokenInterface $token,
477: string $headerName = HttpHeader::AUTHORIZATION
478: ) {
479: return $this->set(
480: $headerName,
481: sprintf('%s %s', $token->getTokenType(), $token->getToken())
482: );
483: }
484:
485: /**
486: * @inheritDoc
487: */
488: public function canonicalize()
489: {
490: return $this->maybeReplaceHeaders($this->Headers, $this->Index, true);
491: }
492:
493: /**
494: * @inheritDoc
495: */
496: public function sort()
497: {
498: return $this->maybeReplaceHeaders(
499: Arr::sort($this->Headers, true, fn($a, $b) => array_key_first($a) <=> array_key_first($b)),
500: Arr::sortByKey($this->Index),
501: true,
502: );
503: }
504:
505: /**
506: * @inheritDoc
507: */
508: public function reverse()
509: {
510: return $this->maybeReplaceHeaders(
511: array_reverse($this->Headers, true),
512: array_reverse($this->Index, true),
513: true,
514: );
515: }
516:
517: /**
518: * @inheritDoc
519: */
520: public function map(callable $callback, int $mode = CollectionInterface::CALLBACK_USE_VALUE)
521: {
522: $items = [];
523: $prev = null;
524: $item = null;
525: $key = null;
526:
527: foreach ($this->Index as $nextLower => $headerIndex) {
528: $nextValue = [];
529: $nextKey = null;
530: foreach ($headerIndex as $i) {
531: $header = $this->Headers[$i];
532: $nextValue[] = reset($header);
533: $nextKey ??= key($header);
534: }
535: $next = $this->getCallbackValue($mode, $nextLower, $nextValue);
536: if ($item !== null) {
537: /** @disregard P1006 */
538: foreach ($callback($item, $next, $prev) as $value) {
539: /** @var string $key */
540: $items[$key][] = $value;
541: }
542: $prev = $item;
543: }
544: $item = $next;
545: $key = $nextKey ?? $nextLower;
546: }
547: if ($item !== null) {
548: /** @disregard P1006 */
549: foreach ($callback($item, null, $prev) as $value) {
550: /** @var string $key */
551: $items[$key][] = $value;
552: }
553: }
554:
555: return new static($items);
556: }
557:
558: /**
559: * @inheritDoc
560: */
561: public function filter(callable $callback, int $mode = CollectionInterface::CALLBACK_USE_VALUE)
562: {
563: $index = $this->Index;
564: $prev = null;
565: $item = null;
566: $lower = null;
567: $changed = false;
568:
569: foreach ($index as $nextLower => $headerIndex) {
570: $nextValue = [];
571: foreach ($headerIndex as $i) {
572: $header = $this->Headers[$i];
573: $nextValue[] = reset($header);
574: }
575: $next = $this->getCallbackValue($mode, $nextLower, $nextValue);
576: if ($item !== null) {
577: if (!$callback($item, $next, $prev)) {
578: unset($index[$lower]);
579: $changed = true;
580: }
581: $prev = $item;
582: }
583: $item = $next;
584: $lower = $nextLower;
585: }
586: if ($item !== null && !$callback($item, null, $prev)) {
587: unset($index[$lower]);
588: $changed = true;
589: }
590:
591: return $changed ? $this->replaceHeaders(null, $index) : $this;
592: }
593:
594: /**
595: * @inheritDoc
596: */
597: public function only(array $keys)
598: {
599: return $this->onlyIn(Arr::toIndex($keys));
600: }
601:
602: /**
603: * @inheritDoc
604: */
605: public function onlyIn(array $index)
606: {
607: return $this->maybeReplaceHeaders(
608: null,
609: array_intersect_key(
610: $this->Index,
611: array_change_key_case($index)
612: )
613: );
614: }
615:
616: /**
617: * @inheritDoc
618: */
619: public function except(array $keys)
620: {
621: return $this->exceptIn(Arr::toIndex($keys));
622: }
623:
624: /**
625: * @inheritDoc
626: */
627: public function exceptIn(array $index)
628: {
629: return $this->maybeReplaceHeaders(
630: null,
631: array_diff_key(
632: $this->Index,
633: array_change_key_case($index)
634: )
635: );
636: }
637:
638: /**
639: * @inheritDoc
640: */
641: public function slice(int $offset, ?int $length = null)
642: {
643: return $this->maybeReplaceHeaders(
644: null,
645: array_slice($this->Index, $offset, $length, true)
646: );
647: }
648:
649: /**
650: * @inheritDoc
651: */
652: public function toArray(): array
653: {
654: return $this->Items;
655: }
656:
657: /**
658: * @inheritDoc
659: */
660: public function jsonSerialize(): array
661: {
662: foreach ($this->headers() as $name => $value) {
663: $headers[] = [
664: 'name' => $name,
665: 'value' => $value,
666: ];
667: }
668: return $headers ?? [];
669: }
670:
671: /**
672: * @inheritDoc
673: */
674: public function push(...$items)
675: {
676: throw new LogicException('HTTP header required');
677: }
678:
679: /**
680: * @inheritDoc
681: */
682: public function pop(&$last = null)
683: {
684: if (!$this->Index) {
685: $last = null;
686: return $this;
687: }
688: $index = $this->Index;
689: array_pop($index);
690: $last = Arr::last($this->Items);
691: return $this->replaceHeaders(null, $index);
692: }
693:
694: /**
695: * @inheritDoc
696: */
697: public function shift(&$first = null)
698: {
699: if (!$this->Index) {
700: $first = null;
701: return $this;
702: }
703: $index = $this->Index;
704: array_shift($index);
705: $first = Arr::first($this->Items);
706: return $this->replaceHeaders(null, $index);
707: }
708:
709: /**
710: * @inheritDoc
711: */
712: public function unshift(...$items)
713: {
714: throw new LogicException('HTTP header required');
715: }
716:
717: /**
718: * @inheritDoc
719: */
720: public function trailers()
721: {
722: return $this->whereIsTrailer();
723: }
724:
725: /**
726: * @inheritDoc
727: */
728: public function withoutTrailers()
729: {
730: return $this->whereIsTrailer(false);
731: }
732:
733: /**
734: * @inheritDoc
735: */
736: public function getLines(
737: string $format = '%s: %s',
738: ?string $emptyFormat = null
739: ): array {
740: foreach ($this->headers() as $key => $value) {
741: if ($emptyFormat !== null && trim($value) === '') {
742: $lines[] = sprintf($emptyFormat, $key, '');
743: continue;
744: }
745: $lines[] = sprintf($format, $key, $value);
746: }
747: return $lines ?? [];
748: }
749:
750: /**
751: * @inheritDoc
752: */
753: public function getHeaders(): array
754: {
755: return $this->doGetHeaders(true);
756: }
757:
758: /**
759: * @inheritDoc
760: */
761: public function getHeaderLines(): array
762: {
763: foreach ($this->Items as $lower => $values) {
764: $lines[$lower] = implode(',', $values);
765: }
766: return $lines ?? [];
767: }
768:
769: /**
770: * @inheritDoc
771: */
772: public function hasHeader(string $name): bool
773: {
774: return isset($this->Items[Str::lower($name)]);
775: }
776:
777: /**
778: * @inheritDoc
779: */
780: public function getHeader(string $name): array
781: {
782: return $this->Items[Str::lower($name)] ?? [];
783: }
784:
785: /**
786: * @inheritDoc
787: */
788: public function getHeaderValues(string $name): array
789: {
790: $values = $this->Items[Str::lower($name)] ?? [];
791: if (!$values) {
792: return [];
793: }
794: // [RFC7230], Section 7: "a recipient MUST parse and ignore a reasonable
795: // number of empty list elements"
796: return Str::splitDelimited(',', implode(',', $values));
797: }
798:
799: /**
800: * @inheritDoc
801: */
802: public function getHeaderLine(string $name): string
803: {
804: return $this->doGetHeaderLine($name);
805: }
806:
807: /**
808: * @inheritDoc
809: */
810: public function getFirstHeaderLine(string $name): string
811: {
812: return $this->doGetHeaderLine($name, true);
813: }
814:
815: /**
816: * @inheritDoc
817: */
818: public function getLastHeaderLine(string $name): string
819: {
820: return $this->doGetHeaderLine($name, false, true);
821: }
822:
823: /**
824: * @inheritDoc
825: */
826: public function getOneHeaderLine(string $name, bool $orSame = false): string
827: {
828: return $this->doGetHeaderLine($name, false, false, true, $orSame);
829: }
830:
831: private function doGetHeaderLine(
832: string $name,
833: bool $first = false,
834: bool $last = false,
835: bool $one = false,
836: bool $orSame = false
837: ): string {
838: $values = $this->getHeaderValues($name);
839: if (!$values) {
840: return '';
841: }
842: if ($first) {
843: return reset($values);
844: }
845: if ($last) {
846: return end($values);
847: }
848: if ($one) {
849: if ($orSame) {
850: $values = array_unique($values);
851: }
852: if (count($values) > 1) {
853: throw new InvalidHeaderException(sprintf(
854: 'HTTP header has more than one value: %s',
855: $name,
856: ));
857: }
858: return reset($values);
859: }
860: return implode(', ', $values);
861: }
862:
863: /**
864: * @return iterable<string,string>
865: */
866: protected function headers(): iterable
867: {
868: foreach ($this->Headers as $header) {
869: $value = reset($header);
870: $key = key($header);
871: yield $key => $value;
872: }
873: }
874:
875: /**
876: * @param string[] $a
877: * @param string[] $b
878: */
879: protected function compareItems($a, $b): int
880: {
881: return $a <=> $b;
882: }
883:
884: protected function filterName(string $name): string
885: {
886: if (!Regex::match(self::HTTP_HEADER_FIELD_NAME, $name)) {
887: throw new InvalidArgumentException(sprintf('Invalid header name: %s', $name));
888: }
889: return $name;
890: }
891:
892: protected function filterValue(string $value): string
893: {
894: $value = Regex::replace('/\r\n\h+/', ' ', trim($value, " \t"));
895: if (!Regex::match(self::HTTP_HEADER_FIELD_VALUE, $value)) {
896: throw new InvalidArgumentException(sprintf('Invalid header value: %s', $value));
897: }
898: return $value;
899: }
900:
901: /**
902: * @param array<int,non-empty-array<string,string>>|null $headers
903: * @param array<string,int[]> $index
904: * @return static
905: */
906: protected function maybeReplaceHeaders(?array $headers, array $index, bool $filterHeaders = false)
907: {
908: $headers ??= $this->getIndexHeaders($index);
909:
910: if ($filterHeaders) {
911: $headers = $this->filterHeaders($headers);
912: }
913:
914: if ($headers === $this->Headers && $index === $this->Index) {
915: return $this;
916: }
917:
918: return $this->replaceHeaders($headers, $index);
919: }
920:
921: /**
922: * @param array<int,non-empty-array<string,string>>|null $headers
923: * @param array<string,int[]> $index
924: * @return static
925: */
926: protected function replaceHeaders(?array $headers, array $index)
927: {
928: $headers ??= $this->getIndexHeaders($index);
929:
930: $clone = clone $this;
931: $clone->Headers = $headers;
932: $clone->Index = $clone->filterByLower($index);
933: $clone->Items = $clone->doGetHeaders();
934: return $clone;
935: }
936:
937: /**
938: * @return array<string,string[]>
939: */
940: protected function doGetHeaders(bool $preserveCase = false): array
941: {
942: foreach ($this->Index as $lower => $headerIndex) {
943: if ($preserveCase) {
944: unset($key);
945: } else {
946: $key = $lower;
947: }
948: foreach ($headerIndex as $i) {
949: $header = $this->Headers[$i];
950: $value = reset($header);
951: $key ??= key($header);
952: $headers[$key][] = $value;
953: }
954: }
955: return $headers ?? [];
956: }
957:
958: /**
959: * @param array<string,int[]> $index
960: * @return array<int,non-empty-array<string,string>>
961: */
962: protected function getIndexHeaders(array $index): array
963: {
964: foreach ($index as $headerIndex) {
965: foreach ($headerIndex as $i) {
966: $headers[$i] = null;
967: }
968: }
969: return array_intersect_key($this->Headers, $headers ?? []);
970: }
971:
972: /**
973: * @param array<int,non-empty-array<string,string>> $headers
974: * @return array<int,non-empty-array<string,string>>
975: */
976: private function filterHeaders(array $headers): array
977: {
978: $host = [];
979: foreach ($headers as $i => $header) {
980: if (isset(array_change_key_case($header)['host'])) {
981: $host[$i] = $header;
982: }
983: }
984: return $host + $headers;
985: }
986:
987: /**
988: * @template T
989: *
990: * @param array<string,T> $index
991: * @return array<string,T>
992: */
993: private function filterByLower(array $index): array
994: {
995: // According to [RFC7230] Section 5.4, "a user agent SHOULD generate
996: // Host as the first header field following the request-line"
997: if (isset($index['host'])) {
998: $index = ['host' => $index['host']] + $index;
999: }
1000: return $index;
1001: }
1002:
1003: /**
1004: * @param array<array<string,string>> $headers
1005: * @param array<string,int[]> $index
1006: * @return array<string,array<array<string,string>>>
1007: */
1008: protected function getIndexValues(array $headers, array $index): array
1009: {
1010: foreach ($index as $lower => $headerIndex) {
1011: foreach ($headerIndex as $i) {
1012: $_headers[$lower][] = $headers[$i];
1013: }
1014: }
1015: return $_headers ?? [];
1016: }
1017:
1018: /**
1019: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string> $items
1020: * @param array<int,non-empty-array<string,string>>|null $headers
1021: * @param array<string,int[]>|null $index
1022: * @param-out array<int,non-empty-array<string,string>> $headers
1023: * @param-out array<string,int[]> $index
1024: * @return array<string,string[]>
1025: */
1026: protected function getItemsArray($items, ?array &$headers = null, ?array &$index = null): array
1027: {
1028: $headers = [];
1029: $index = [];
1030: $i = -1;
1031: foreach ($this->getItems($items) as $key => $values) {
1032: $lower = Str::lower($key);
1033: foreach ($values as $value) {
1034: $headers[++$i] = [$key => $value];
1035: $index[$lower][] = $i;
1036: $array[$lower][] = $value;
1037: }
1038: }
1039:
1040: return $array ?? [];
1041: }
1042:
1043: /**
1044: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string> $items
1045: * @return iterable<string,string[]>
1046: */
1047: protected function getItems($items): iterable
1048: {
1049: if ($items instanceof self) {
1050: $items = $items->headers();
1051: } elseif ($items instanceof Arrayable) {
1052: /** @var array<string,string[]|string> */
1053: $items = $items->toArray();
1054: }
1055: yield from $this->filterItems($items);
1056: }
1057:
1058: /**
1059: * @param iterable<string,string[]|string> $items
1060: * @return iterable<string,string[]>
1061: */
1062: protected function filterItems(iterable $items): iterable
1063: {
1064: foreach ($items as $key => $value) {
1065: $values = (array) $value;
1066: if (!$values) {
1067: throw new InvalidArgumentException(sprintf(
1068: 'At least one value must be given for HTTP header: %s',
1069: $key,
1070: ));
1071: }
1072: $key = $this->filterName($key);
1073: $filtered = [];
1074: foreach ($values as $value) {
1075: $filtered[] = $this->filterValue($value);
1076: }
1077: yield $key => $filtered;
1078: }
1079: }
1080:
1081: /**
1082: * @return static
1083: */
1084: private function extendLast(string $line)
1085: {
1086: if (!$this->Headers) {
1087: return $this;
1088: }
1089: $headers = $this->Headers;
1090: $i = array_key_last($headers);
1091: $header = $this->Headers[$i];
1092: $value = reset($header);
1093: $key = key($header);
1094: $headers[$i][$key] = ltrim($value . $line);
1095: return $this->maybeReplaceHeaders($headers, $this->Index);
1096: }
1097:
1098: /**
1099: * @return static
1100: */
1101: private function maybeIndexTrailer()
1102: {
1103: if (!$this->Headers) {
1104: // @codeCoverageIgnoreStart
1105: throw new LogicException('No headers applied');
1106: // @codeCoverageIgnoreEnd
1107: }
1108: if (!$this->Closed) {
1109: return $this;
1110: }
1111: $i = array_key_last($this->Headers);
1112: $trailers = $this->Trailers;
1113: $trailers[$i] = true;
1114: return $this->with('Trailers', $trailers);
1115: }
1116:
1117: /**
1118: * @return static
1119: */
1120: private function whereIsTrailer(bool $value = true)
1121: {
1122: $headers = [];
1123: $index = [];
1124: foreach ($this->Index as $lower => $headerIndex) {
1125: foreach ($headerIndex as $i) {
1126: $isTrailer = $this->Trailers[$i] ?? false;
1127: if ($value xor $isTrailer) {
1128: continue;
1129: }
1130: $headers[$i] = $this->Headers[$i];
1131: $index[$lower][] = $i;
1132: }
1133: }
1134: ksort($headers);
1135: return $this->maybeReplaceHeaders($headers, $index);
1136: }
1137: }
1138: