friendship ended with social-app. php is my new best friend
1<?php 2/** 3 * Class MessageUtil 4 * 5 * @created 22.10.2022 6 * @author smiley <smiley@chillerlan.net> 7 * @copyright 2022 smiley 8 * @license MIT 9 */ 10declare(strict_types=1); 11 12namespace chillerlan\HTTP\Utils; 13 14use Psr\Http\Message\{MessageInterface, RequestInterface, ResponseInterface, ServerRequestInterface}; 15use DateInterval, DateTimeInterface, RuntimeException, Throwable; 16use function array_map, explode, extension_loaded, function_exists, gzdecode, gzinflate, gzuncompress, implode, 17 in_array, json_decode, json_encode, rawurldecode, simplexml_load_string, sprintf, strtolower, trim; 18use const JSON_PRETTY_PRINT, JSON_THROW_ON_ERROR, JSON_UNESCAPED_SLASHES; 19 20final class MessageUtil{ 21 22 /** 23 * Read the message body's content, throws if the content could not be read from the message body 24 * 25 * @throws \RuntimeException 26 */ 27 public static function getContents(MessageInterface $message):string{ 28 $content = StreamUtil::getContents($message->getBody()); 29 30 if($content === null){ 31 throw new RuntimeException('invalid message content'); // @codeCoverageIgnore 32 } 33 34 return $content; 35 } 36 37 /** 38 * @throws \JsonException 39 */ 40 public static function decodeJSON(MessageInterface $message, bool|null $assoc = null):mixed{ 41 return json_decode(self::decompress($message), ($assoc ?? false), 512, JSON_THROW_ON_ERROR); 42 } 43 44 public static function decodeXML(MessageInterface $message, bool|null $assoc = null):mixed{ 45 $data = simplexml_load_string(self::decompress($message)); 46 47 if($assoc === true){ 48 return json_decode(json_encode($data), true); // cruel 49 } 50 51 return $data; 52 } 53 54 /** 55 * Returns the string representation of an HTTP message. (from Guzzle) 56 */ 57 public static function toString(MessageInterface $message, bool|null $appendBody = null):string{ 58 $appendBody ??= true; 59 $msg = ''; 60 61 if($message instanceof RequestInterface){ 62 $msg = sprintf( 63 '%s %s HTTP/%s', 64 $message->getMethod(), 65 $message->getRequestTarget(), 66 $message->getProtocolVersion(), 67 ); 68 69 if(!$message->hasHeader('host')){ 70 $msg .= sprintf("\r\nHost: %s", $message->getUri()->getHost()); 71 } 72 73 } 74 elseif($message instanceof ResponseInterface){ 75 $msg = sprintf( 76 'HTTP/%s %s %s', 77 $message->getProtocolVersion(), 78 $message->getStatusCode(), 79 $message->getReasonPhrase(), 80 ); 81 } 82 83 foreach($message->getHeaders() as $name => $values){ 84 $msg .= sprintf("\r\n%s: %s", $name, implode(', ', $values)); 85 } 86 87 // appending the body might cause issues in some cases, e.g. with large responses or file streams 88 if($appendBody === true){ 89 $msg .= sprintf("\r\n\r\n%s", self::decompress($message)); 90 } 91 92 return $msg; 93 } 94 95 /** 96 * Returns a JSON representation of an HTTP message. 97 */ 98 public static function toJSON(MessageInterface $message, bool|null $appendBody = null):string{ 99 $appendBody ??= true; 100 $msg = ['headers' => []]; 101 102 if($message instanceof RequestInterface){ 103 $uri = $message->getUri(); 104 105 $msg['request'] = [ 106 'url' => (string)$uri, 107 'params' => QueryUtil::parse($uri->getQuery()), 108 'method' => $message->getMethod(), 109 'target' => $message->getRequestTarget(), 110 'http' => $message->getProtocolVersion(), 111 ]; 112 113 if(!$message->hasHeader('host')){ 114 $msg['headers']['Host'] = $message->getUri()->getHost(); 115 } 116 117 } 118 elseif($message instanceof ResponseInterface){ 119 $msg['response'] = [ 120 'status' => $message->getStatusCode(), 121 'reason' => $message->getReasonPhrase(), 122 'http' => $message->getProtocolVersion(), 123 ]; 124 } 125 126 foreach($message->getHeaders() as $name => $values){ 127 $msg['headers'][$name] = implode(', ', $values); 128 } 129 130 if($appendBody === true){ 131 $msg['body'] = self::decompress($message); 132 } 133 134 return json_encode($msg, (JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 135 } 136 137 /** 138 * Decompresses the message content according to the Content-Encoding header and returns the decompressed data 139 * 140 * @see https://github.com/kjdev/php-ext-brotli 141 * @see https://github.com/kjdev/php-ext-zstd 142 * @see https://en.wikipedia.org/wiki/HTTP_compression#Content-Encoding_tokens 143 * 144 * @throws \Throwable|\RuntimeException 145 */ 146 public static function decompress(MessageInterface $message):string{ 147 $data = self::getContents($message); 148 $encoding = strtolower($message->getHeaderLine('content-encoding')); 149 150 try{ 151 $decoded = match($encoding){ 152 '', 'identity' => $data, 153 'gzip', 'x-gzip' => gzdecode($data), 154 'compress' => gzuncompress($data), 155 'deflate' => gzinflate($data), 156 'br' => self::call_decompress_func('brotli', $data), 157 'zstd' => self::call_decompress_func('zstd', $data), 158 }; 159 160 if($decoded === false){ 161 throw new RuntimeException; 162 } 163 164 return $decoded; 165 } 166 catch(Throwable $e){ 167 if(in_array($encoding, ['br', 'zstd'], true)){ 168 throw $e; 169 } 170 } 171 172 throw new RuntimeException('unknown content-encoding value: '.$encoding); 173 } 174 175 /** 176 * @codeCoverageIgnore 177 */ 178 protected static function call_decompress_func(string $func, string $data):string{ 179 $fn = $func.'_uncompress'; 180 181 if(!extension_loaded($func) || !function_exists($fn)){ 182 throw new RuntimeException(sprintf('cannot decompress %s compressed message body', $func)); 183 } 184 185 return $fn($data); 186 } 187 188 /** 189 * Sets a Content-Length header in the given message in case it does not exist and body size is not null 190 */ 191 public static function setContentLengthHeader( 192 MessageInterface $message, 193 ):MessageInterface|RequestInterface|ResponseInterface|ServerRequestInterface{ 194 $bodySize = $message->getBody()->getSize(); 195 196 if(!$message->hasHeader('Content-Length') && $bodySize !== null && $bodySize > 0){ 197 $message = $message->withHeader('Content-Length', (string)$bodySize); 198 } 199 200 return $message; 201 } 202 203 /** 204 * Tries to determine the content type from the given values and sets the Content-Type header accordingly, 205 * throws if no mime type could be guessed. 206 * 207 * @throws \RuntimeException 208 */ 209 public static function setContentTypeHeader( 210 MessageInterface $message, 211 string|null $filename = null, 212 string|null $extension = null, 213 ):MessageInterface|RequestInterface|ResponseInterface|ServerRequestInterface{ 214 $mime = ( 215 MimeTypeUtil::getFromExtension(trim(($extension ?? ''), ".\t\n\r\0\x0B")) 216 ?? MimeTypeUtil::getFromFilename(($filename ?? '')) 217 ?? MimeTypeUtil::getFromContent(self::getContents($message)) 218 ); 219 220 if($mime === null){ 221 throw new RuntimeException('could not determine content type'); // @codeCoverageIgnore 222 } 223 224 return $message->withHeader('Content-Type', $mime); 225 } 226 227 /** 228 * Adds a Set-Cookie header to a ResponseInterface (convenience) 229 * 230 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie 231 */ 232 public static function setCookie( 233 ResponseInterface $message, 234 string $name, 235 string|null $value = null, 236 DateTimeInterface|DateInterval|int|null $expiry = null, 237 string|null $domain = null, 238 string|null $path = null, 239 bool $secure = false, 240 bool $httpOnly = false, 241 string|null $sameSite = null, 242 ):ResponseInterface{ 243 244 $cookie = (new Cookie($name, $value)) 245 ->withExpiry($expiry) 246 ->withDomain($domain) 247 ->withPath($path) 248 ->withSecure($secure) 249 ->withHttpOnly($httpOnly) 250 ->withSameSite($sameSite) 251 ; 252 253 return $message->withAddedHeader('Set-Cookie', (string)$cookie); 254 } 255 256 /** 257 * Attempts to extract and parse a cookie from a "Cookie" (user-agent) header 258 * 259 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie 260 * 261 * @return array<string, string>|null 262 */ 263 public static function getCookiesFromHeader(MessageInterface $message):array|null{ 264 265 if(!$message->hasHeader('Cookie')){ 266 return null; 267 } 268 269 $header = trim($message->getHeaderLine('Cookie')); 270 271 if(empty($header)){ 272 return null; 273 } 274 275 $cookies = []; 276 277 // some people apparently use regular expressions for this (: 278 foreach(array_map(trim(...), explode(';', $header)) as $kv){ 279 [$name, $value] = array_map(trim(...), explode('=', $kv, 2)); 280 281 $cookies[$name] = rawurldecode($value); 282 } 283 284 return $cookies; 285 } 286 287}