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}