1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\MessageInterface as PsrMessageInterface;
6: use Psr\Http\Message\RequestInterface as PsrRequestInterface;
7: use Psr\Http\Message\StreamInterface as PsrStreamInterface;
8: use Psr\Http\Message\UriInterface as PsrUriInterface;
9: use Salient\Contract\Core\Arrayable;
10: use Salient\Contract\Core\DateFormatterInterface;
11: use Salient\Contract\Http\Message\MultipartStreamInterface;
12: use Salient\Contract\Http\HasFormDataFlag;
13: use Salient\Contract\Http\HasHttpHeader;
14: use Salient\Contract\Http\HasMediaType;
15: use Salient\Contract\Http\HasRequestMethod;
16: use Salient\Contract\HasHmacHashAlgorithm;
17: use Salient\Http\Exception\InvalidHeaderException;
18: use Salient\Http\Internal\FormDataEncoder;
19: use Salient\Http\Internal\TOTPGenerator;
20: use Salient\Utility\AbstractUtility;
21: use Salient\Utility\Date;
22: use Salient\Utility\Package;
23: use Salient\Utility\Reflect;
24: use Salient\Utility\Regex;
25: use Salient\Utility\Str;
26: use Salient\Utility\Test;
27: use DateTimeInterface;
28: use DateTimeZone;
29: use Stringable;
30:
31: /**
32: * @api
33: */
34: final class HttpUtil extends AbstractUtility implements
35: HasFormDataFlag,
36: HasHmacHashAlgorithm,
37: HasHttpHeader,
38: HasHttpRegex,
39: HasMediaType,
40: HasRequestMethod
41: {
42: /**
43: * @link https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml
44: */
45: private const SUFFIX_TYPE = [
46: 'gzip' => self::TYPE_GZIP,
47: 'json' => self::TYPE_JSON,
48: 'jwt' => self::TYPE_JWT,
49: 'xml' => self::TYPE_XML,
50: 'yaml' => self::TYPE_YAML,
51: 'zip' => self::TYPE_ZIP,
52: ];
53:
54: private const ALIAS_TYPE = [
55: 'text/xml' => self::TYPE_XML,
56: ];
57:
58: /**
59: * Get the value of a Content-Length header, or null if it is not set
60: *
61: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|PsrMessageInterface|string $headersOrPayload
62: * @return int<0,max>|null
63: */
64: public static function getContentLength($headersOrPayload): ?int
65: {
66: $headers = Headers::from($headersOrPayload);
67: if (!$headers->hasHeader(self::HEADER_CONTENT_LENGTH)) {
68: return null;
69: }
70: $length = $headers->getOnlyHeaderValue(self::HEADER_CONTENT_LENGTH, true);
71: if (!Test::isInteger($length) || (int) $length < 0) {
72: throw new InvalidHeaderException(sprintf(
73: 'Invalid value for HTTP header %s: %s',
74: self::HEADER_CONTENT_LENGTH,
75: $length,
76: ));
77: }
78: return (int) $length;
79: }
80:
81: /**
82: * Get the value of a Content-Type header's boundary parameter, or null if
83: * it is not set
84: *
85: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|PsrMessageInterface|string $headersOrPayload
86: */
87: public static function getMultipartBoundary($headersOrPayload): ?string
88: {
89: $headers = Headers::from($headersOrPayload);
90: if (!$headers->hasHeader(self::HEADER_CONTENT_TYPE)) {
91: return null;
92: }
93: $type = $headers->getLastHeaderValue(self::HEADER_CONTENT_TYPE);
94: return self::getParameters($type, false, false)['boundary'] ?? null;
95: }
96:
97: /**
98: * Get preferences applied via one or more Prefer headers as per [RFC7240]
99: *
100: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|PsrMessageInterface|string $headersOrPayload
101: * @return array<string,array{value:string,parameters:array<string,string>}>
102: */
103: public static function getPreferences($headersOrPayload): array
104: {
105: $headers = Headers::from($headersOrPayload);
106: if (!$headers->hasHeader(self::HEADER_PREFER)) {
107: return [];
108: }
109: foreach ($headers->getHeaderValues(self::HEADER_PREFER) as $pref) {
110: /** @var array<string,string> */
111: $params = self::getParameters($pref, true);
112: if (!$params) {
113: continue;
114: }
115: $value = reset($params);
116: $name = key($params);
117: unset($params[$name]);
118: $prefs[$name] ??= ['value' => $value, 'parameters' => $params];
119: }
120: return $prefs ?? [];
121: }
122:
123: /**
124: * Merge preferences into a Prefer header value as per [RFC7240]
125: *
126: * @param array<string,array{value:string,parameters?:array<string,string>}|string> $preferences
127: */
128: public static function mergePreferences(array $preferences): string
129: {
130: foreach ($preferences as $name => $pref) {
131: $lower = Str::lower($name);
132: $prefs[$lower] ??= self::mergeParameters(
133: is_string($pref)
134: ? [$name => $pref]
135: : [$name => $pref['value']] + ($pref['parameters'] ?? []),
136: );
137: }
138: return implode(', ', $prefs ?? []);
139: }
140:
141: /**
142: * Get the value of a Retry-After header in seconds from the current time,
143: * or null if it has an invalid value or is not set
144: *
145: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|PsrMessageInterface|string $headersOrPayload
146: * @return int<0,max>|null
147: */
148: public static function getRetryAfter($headersOrPayload): ?int
149: {
150: $headers = Headers::from($headersOrPayload);
151: $after = $headers->getHeaderLine(self::HEADER_RETRY_AFTER);
152: if (!Test::isInteger($after)) {
153: $after = strtotime($after);
154: return $after === false
155: ? null
156: : max(0, $after - time());
157: }
158: return (int) $after < 0
159: ? null
160: : (int) $after;
161: }
162:
163: /**
164: * Get semicolon-delimited parameters from the value of a header
165: *
166: * @return string[]
167: */
168: public static function getParameters(
169: string $value,
170: bool $firstIsParameter = false,
171: bool $unquote = true,
172: bool $strict = false
173: ): array {
174: foreach (Str::splitDelimited(';', $value, false) as $i => $param) {
175: if ($i === 0 && !$firstIsParameter) {
176: $params[] = $unquote
177: ? self::unquoteString($param)
178: : $param;
179: } elseif (Regex::match(
180: '/^(' . self::HTTP_TOKEN . ')(?:\h*+=\h*+(.*))?$/D',
181: $param,
182: $matches,
183: )) {
184: $param = $matches[2] ?? '';
185: $params[Str::lower($matches[1])] = $unquote
186: ? self::unquoteString($param)
187: : $param;
188: } elseif ($strict) {
189: throw new InvalidHeaderException(
190: sprintf('Invalid HTTP header parameter: %s', $param),
191: );
192: }
193: }
194: return $params ?? [];
195: }
196:
197: /**
198: * Merge parameters into a semicolon-delimited header value
199: *
200: * @param string[] $parameters
201: */
202: public static function mergeParameters(array $parameters): string
203: {
204: if (!$parameters) {
205: return '';
206: }
207:
208: foreach ($parameters as $key => $param) {
209: $value = is_int($key) ? [] : [$key];
210: if ($param !== '') {
211: $value[] = self::maybeQuoteString($param);
212: }
213: $merged[] = $last = implode('=', $value);
214: }
215: $merged = implode('; ', $merged);
216: // @phpstan-ignore notIdentical.alwaysTrue (`$merged` may be empty)
217: return $last === '' && $merged !== ''
218: ? substr($merged, 0, -1)
219: : $merged;
220: }
221:
222: /**
223: * Check if a string is a recognised HTTP request method
224: *
225: * @phpstan-assert-if-true HttpUtil::METHOD_* $method
226: */
227: public static function isRequestMethod(string $method): bool
228: {
229: return Reflect::hasConstantWithValue(HasRequestMethod::class, $method);
230: }
231:
232: /**
233: * Check if a string contains only a host and port number, separated by a
234: * colon
235: *
236: * \[RFC9112] Section 3.2.3 ("authority-form"): "When making a CONNECT
237: * request to establish a tunnel through one or more proxies, a client MUST
238: * send only the host and port of the tunnel destination as the
239: * request-target."
240: */
241: public static function isAuthorityForm(string $target): bool
242: {
243: return (bool) Regex::match(self::AUTHORITY_FORM_REGEX, $target);
244: }
245:
246: /**
247: * Check if a media type is a match for the given MIME type
248: *
249: * Structured syntax suffixes (e.g. `+json` in `application/vnd.api+json`)
250: * are parsed as per \[RFC6838] Section 4.2.8 ("Structured Syntax Name
251: * Suffixes").
252: */
253: public static function mediaTypeIs(string $type, string $mimeType): bool
254: {
255: // Extract and normalise the type and subtype
256: [$type] = explode(';', $type, 2);
257: $type = Str::lower(rtrim($type));
258: if ((self::ALIAS_TYPE[$type] ?? $type) === $mimeType) {
259: return true;
260: }
261:
262: // Check for a structured syntax suffix
263: $pos = strrpos($type, '+');
264: if ($pos !== false) {
265: $suffix = substr($type, $pos + 1);
266: $type = substr($type, 0, $pos);
267: return (self::ALIAS_TYPE[$type] ?? $type) === $mimeType
268: || (self::SUFFIX_TYPE[$suffix] ?? null) === $mimeType;
269: }
270: return false;
271: }
272:
273: /**
274: * Get an HTTP date as per [RFC9110] Section 5.6.7 ("Date/Time Formats")
275: */
276: public static function getDate(?DateTimeInterface $date = null): string
277: {
278: return Date::immutable($date)
279: ->setTimezone(new DateTimeZone('UTC'))
280: ->format('D, d M Y H:i:s \G\M\T');
281: }
282:
283: /**
284: * Get a product identifier suitable for User-Agent and Server headers as
285: * per [RFC9110] Section 10.1.5 ("User-Agent")
286: */
287: public static function getProduct(): string
288: {
289: return sprintf(
290: '%s/%s php/%s',
291: str_replace('/', '~', Package::name()),
292: Package::version(true, true),
293: \PHP_VERSION,
294: );
295: }
296:
297: /**
298: * Get the media type of a multipart stream
299: */
300: public static function getMultipartMediaType(MultipartStreamInterface $stream): string
301: {
302: return sprintf(
303: '%s; boundary=%s',
304: self::TYPE_FORM_MULTIPART,
305: self::maybeQuoteString($stream->getBoundary()),
306: );
307: }
308:
309: /**
310: * Escape and double-quote a string unless it is a valid HTTP token, as per
311: * [RFC9110] Section 5.6.4 ("Quoted Strings")
312: */
313: public static function maybeQuoteString(string $string): string
314: {
315: return Regex::match(self::HTTP_TOKEN_REGEX, $string)
316: ? $string
317: : self::quoteString($string);
318: }
319:
320: /**
321: * Escape and double-quote a string as per [RFC9110] Section 5.6.4 ("Quoted
322: * Strings")
323: */
324: public static function quoteString(string $string): string
325: {
326: return '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $string) . '"';
327: }
328:
329: /**
330: * Unescape and remove quotes from a string as per [RFC9110] Section 5.6.4
331: * ("Quoted Strings")
332: */
333: public static function unquoteString(string $string): string
334: {
335: $string = Regex::replace('/^"(.*)"$/D', '$1', $string, -1, $count);
336: return $count
337: ? Regex::replace('/\\\\(.)/', '$1', $string)
338: : $string;
339: }
340:
341: /**
342: * Merge values into the query string of a request or URI
343: *
344: * @template T of PsrRequestInterface|PsrUriInterface|Stringable|string
345: *
346: * @param T $value
347: * @param mixed[] $data
348: * @param int-mask-of<HttpUtil::DATA_*> $flags
349: * @return (T is PsrRequestInterface|PsrUriInterface ? T : Uri)
350: */
351: public static function mergeQuery(
352: $value,
353: array $data,
354: int $flags = HttpUtil::DATA_PRESERVE_NUMERIC_KEYS | HttpUtil::DATA_PRESERVE_STRING_KEYS,
355: ?DateFormatterInterface $dateFormatter = null
356: ) {
357: /** @todo Replace with `parse_str()` alternative */
358: parse_str(($value instanceof PsrRequestInterface
359: ? $value->getUri()
360: : ($value instanceof PsrUriInterface
361: ? $value
362: : new Uri((string) $value)))->getQuery(), $query);
363: $data = array_replace_recursive($query, $data);
364: return self::replaceQuery($value, $data, $flags, $dateFormatter);
365: }
366:
367: /**
368: * Replace the query string of a request or URI with the given values
369: *
370: * @template T of PsrRequestInterface|PsrUriInterface|Stringable|string
371: *
372: * @param T $value
373: * @param mixed[] $data
374: * @param int-mask-of<HttpUtil::DATA_*> $flags
375: * @return (T is PsrRequestInterface|PsrUriInterface ? T : Uri)
376: */
377: public static function replaceQuery(
378: $value,
379: array $data,
380: int $flags = HttpUtil::DATA_PRESERVE_NUMERIC_KEYS | HttpUtil::DATA_PRESERVE_STRING_KEYS,
381: ?DateFormatterInterface $dateFormatter = null
382: ) {
383: $query = (new FormDataEncoder($flags, $dateFormatter))->getQuery($data);
384: return $value instanceof PsrRequestInterface
385: ? $value->withUri($value->getUri()->withQuery($query))
386: : ($value instanceof PsrUriInterface
387: ? $value
388: : new Uri((string) $value))->withQuery($query);
389: }
390:
391: /**
392: * Get the contents of a stream
393: */
394: public static function getStreamContents(PsrStreamInterface $from): string
395: {
396: $buffer = '';
397: while (!$from->eof()) {
398: $data = $from->read(1048576);
399: if ($data === '') {
400: break;
401: }
402: $buffer .= $data;
403: }
404: return $buffer;
405: }
406:
407: /**
408: * Copy the contents of one stream to another
409: */
410: public static function copyStream(PsrStreamInterface $from, PsrStreamInterface $to): void
411: {
412: $buffer = '';
413: while (!$from->eof()) {
414: $data = $from->read(8192);
415: if ($data === '') {
416: break;
417: }
418: $buffer .= $data;
419: $buffer = substr($buffer, $to->write($buffer));
420: }
421: while ($buffer !== '') {
422: $buffer = substr($buffer, $to->write($buffer));
423: }
424: }
425:
426: /**
427: * Iterate over name-value pairs in arrays with "name" and "value" keys
428: *
429: * @param iterable<array{name:string,value:string}> $items
430: * @return iterable<string,string>
431: */
432: public static function getNameValuePairs(iterable $items): iterable
433: {
434: foreach ($items as $item) {
435: yield $item['name'] => $item['value'];
436: }
437: }
438:
439: /**
440: * Get a time-based one-time password as per [RFC6238]
441: *
442: * @param string $key Base32-encoded shared key.
443: * @param HttpUtil::ALGORITHM_* $algorithm
444: */
445: public static function getTOTP(
446: string $key,
447: int $digits = 6,
448: string $algorithm = HttpUtil::ALGORITHM_SHA1,
449: int $timeStep = 30,
450: int $startTime = 0
451: ): string {
452: return (new TOTPGenerator($digits, $algorithm, $timeStep, $startTime))->getPassword($key);
453: }
454: }
455: