friendship ended with social-app. php is my new best friend
1<?php
2/**
3 * Class HeaderUtil
4 *
5 * @created 28.03.2021
6 * @author smiley <smiley@chillerlan.net>
7 * @copyright 2021 smiley
8 * @license MIT
9 */
10declare(strict_types=1);
11
12namespace chillerlan\HTTP\Utils;
13
14use InvalidArgumentException;
15use function array_keys, array_values, count, explode, implode, is_array,
16 is_numeric, is_scalar, is_string, str_replace, strtolower, trim, ucfirst;
17
18final class HeaderUtil{
19
20 /**
21 * Normalizes an array of header lines to format ["Name" => "Value (, Value2, Value3, ...)", ...]
22 * An exception is being made for Set-Cookie, which holds an array of values for each cookie.
23 * For multiple cookies with the same name, only the last value will be kept.
24 *
25 * @param array<int|string, scalar|bool|array<int, scalar|bool|null>|null> $headers
26 * @return array<string, string>
27 */
28 public static function normalize(iterable $headers):array{
29 $normalized = [];
30
31 foreach($headers as $key => $val){
32
33 // the key is numeric, so $val is either a string or an array that contains both
34 if(is_numeric($key)){
35 [$key, $val] = self::normalizeKV($val);
36
37 if($key === null){
38 continue;
39 }
40 }
41
42 $key = self::normalizeHeaderName($key);
43
44 // cookie headers may appear multiple times - we'll just collect the last value here
45 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2
46 if($key === 'Set-Cookie'){
47 $name = fn(string $v):string => trim(strtolower(explode('=', $v, 2)[0]));
48
49 // array received from Message::getHeaders()
50 if(is_array($val)){
51 foreach(self::trimValues($val) as $line){
52 $normalized[$key][$name($line)] = trim($line);
53 }
54 }
55 else{
56 $val = self::trimValues([$val])[0];
57
58 $normalized[$key][$name($val)] = $val;
59 }
60 }
61 // combine header fields with the same name
62 // https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
63 else{
64
65 if(!is_array($val)){
66 $val = [$val];
67 }
68
69 $val = implode(', ', array_values(self::trimValues($val)));
70
71 // skip if the header already exists but the current value is empty
72 if(isset($normalized[$key]) && $val === ''){
73 continue;
74 }
75
76 !empty($normalized[$key])
77 ? $normalized[$key] .= ', '.$val
78 : $normalized[$key] = $val;
79 }
80 }
81
82 return $normalized;
83 }
84
85 /**
86 * Extracts a key:value pair from the given value and returns it as 2-element array.
87 * If the key cannot be parsed, both array values will be `null`.
88 *
89 * @return array{0: string|null, 1: scalar|bool|null}
90 */
91 protected static function normalizeKV(mixed $value):array{
92
93 // "key: val"
94 if(is_string($value)){
95 $kv = explode(':', $value, 2);
96
97 if(count($kv) === 2){
98 return $kv;
99 }
100 }
101 // [$key, $val], ["key" => $key, "val" => $val]
102 elseif(is_array($value) && !empty($value)){
103 $key = array_keys($value)[0];
104 $val = array_values($value)[0];
105
106 if(is_string($key)){
107 return [$key, $val];
108 }
109 }
110
111 return [null, null];
112 }
113
114 /**
115 * Trims whitespace from the header values.
116 *
117 * Spaces and tabs ought to be excluded by parsers when extracting the field value from a header field.
118 *
119 * header-field = field-name ":" OWS field-value OWS
120 * OWS = *( SP / HTAB )
121 *
122 * @see https://tools.ietf.org/html/rfc7230#section-3.2.4
123 * @see https://github.com/advisories/GHSA-wxmh-65f7-jcvw
124 *
125 * @param array<int, scalar|null> $values
126 * @return array<int, string> $values
127 */
128 public static function trimValues(array $values):array{
129
130 foreach($values as &$value){
131
132 if(!is_scalar($value) && $value !== null){
133 throw new InvalidArgumentException('value is expected to be scalar or null');
134 }
135
136 $value = trim(str_replace(["\r", "\n"], '', (string)($value ?? '')));
137 }
138
139 return $values;
140 }
141
142 /**
143 * Normalizes a header name, e.g. "con TENT- lenGTh" -> "Content-Length"
144 *
145 * @see https://github.com/advisories/GHSA-wxmh-65f7-jcvw
146 */
147 public static function normalizeHeaderName(string $name):string{
148 // we'll remove any spaces as well as CRLF in the name, e.g. "con tent" -> "content"
149 $parts = explode('-', str_replace([' ', "\r", "\n"], '', $name));
150
151 foreach($parts as &$part){
152 $part = ucfirst(strtolower(trim($part)));
153 }
154
155 return implode('-', $parts);
156 }
157
158}