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