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}