1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\RequestInterface;
6: use Psr\Http\Message\UriInterface as PsrUriInterface;
7: use Salient\Contract\Core\DateFormatterInterface;
8: use Salient\Contract\Http\FormDataFlag;
9: use Salient\Contract\Http\HttpRequestMethod;
10: use Salient\Contract\Http\MimeType;
11: use Salient\Utility\AbstractUtility;
12: use Salient\Utility\Date;
13: use Salient\Utility\Package;
14: use Salient\Utility\Reflect;
15: use Salient\Utility\Regex;
16: use Salient\Utility\Str;
17: use DateTimeImmutable;
18: use DateTimeInterface;
19: use DateTimeZone;
20: use Generator;
21: use InvalidArgumentException;
22: use Stringable;
23:
24: /**
25: * @api
26: */
27: final class HttpUtil extends AbstractUtility
28: {
29: /**
30: * @link https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml
31: *
32: * @var array<string,string>
33: */
34: private const SUFFIX_TYPE = [
35: 'gzip' => MimeType::GZIP,
36: 'json' => MimeType::JSON,
37: 'jwt' => MimeType::JWT,
38: 'xml' => MimeType::XML,
39: 'yaml' => MimeType::YAML,
40: 'zip' => MimeType::ZIP,
41: ];
42:
43: /**
44: * @var array<string,string>
45: */
46: private const ALIAS_TYPE = [
47: 'text/xml' => MimeType::XML,
48: ];
49:
50: /**
51: * Check if a string is a valid HTTP request method
52: *
53: * @phpstan-assert-if-true HttpRequestMethod::* $method
54: */
55: public static function isRequestMethod(string $method): bool
56: {
57: return Reflect::hasConstantWithValue(HttpRequestMethod::class, $method);
58: }
59:
60: /**
61: * Check if a media type is a match for the given MIME type
62: *
63: * Structured syntax suffixes (e.g. `+json` in `application/vnd.api+json`)
64: * are parsed as per \[RFC6838] Section 4.2.8 ("Structured Syntax Name
65: * Suffixes").
66: */
67: public static function mediaTypeIs(string $type, string $mimeType): bool
68: {
69: // Extract and normalise the type and subtype
70: [$type] = explode(';', $type);
71: $type = Str::lower(rtrim($type));
72:
73: if ((self::ALIAS_TYPE[$type] ?? $type) === $mimeType) {
74: return true;
75: }
76:
77: // Check for a structured syntax suffix
78: $pos = strrpos($type, '+');
79: if ($pos === false) {
80: return false;
81: }
82:
83: $suffix = substr($type, $pos + 1);
84: $type = substr($type, 0, $pos);
85:
86: return (self::ALIAS_TYPE[$type] ?? $type) === $mimeType
87: || (self::SUFFIX_TYPE[$suffix] ?? null) === $mimeType;
88: }
89:
90: /**
91: * Get an HTTP date value as per [RFC7231] Section 7.1.1.1
92: */
93: public static function getDate(?DateTimeInterface $date = null): string
94: {
95: return ($date ? Date::immutable($date) : new DateTimeImmutable())
96: ->setTimezone(new DateTimeZone('UTC'))
97: ->format(DateTimeInterface::RFC7231);
98: }
99:
100: /**
101: * Get semicolon-delimited parameters from the value of an HTTP header
102: *
103: * @return string[]
104: */
105: public static function getParameters(
106: string $value,
107: bool $firstIsParameter = false,
108: bool $unquote = true,
109: bool $strict = false
110: ): array {
111: foreach (Str::splitDelimited(';', $value, false) as $i => $param) {
112: if ($i === 0 && !$firstIsParameter) {
113: $params[] = $unquote
114: ? self::unquoteString($param)
115: : $param;
116: continue;
117: }
118: if (Regex::match('/^(' . Regex::HTTP_TOKEN . ')(?:\h*+=\h*+(.*))?$/D', $param, $matches)) {
119: $param = $matches[2] ?? '';
120: $params[Str::lower($matches[1])] = $unquote
121: ? self::unquoteString($param)
122: : $param;
123: continue;
124: }
125: if ($strict) {
126: throw new InvalidArgumentException(sprintf('Invalid parameter: %s', $param));
127: }
128: }
129: return $params ?? [];
130: }
131:
132: /**
133: * Merge parameters into a semicolon-delimited HTTP header value
134: *
135: * @param string[] $parameters
136: */
137: public static function mergeParameters(array $parameters): string
138: {
139: if (!$parameters) {
140: return '';
141: }
142:
143: foreach ($parameters as $key => $param) {
144: $value = is_int($key) ? [] : [$key];
145: if ($param !== '') {
146: $value[] = self::maybeQuoteString($param);
147: }
148: $merged[] = $last = implode('=', $value);
149: }
150: $merged = implode('; ', $merged);
151: // @phpstan-ignore notIdentical.alwaysTrue ($merged could be empty)
152: return $last === '' && $merged !== ''
153: ? substr($merged, 0, -1)
154: : $merged;
155: }
156:
157: /**
158: * Get a product identifier suitable for User-Agent and Server headers as
159: * per [RFC7231] Section 5.5.3
160: */
161: public static function getProduct(): string
162: {
163: return sprintf(
164: '%s/%s php/%s',
165: str_replace('/', '~', Package::name()),
166: Package::version(true, true),
167: \PHP_VERSION,
168: );
169: }
170:
171: /**
172: * Escape and quote a string unless it is a valid HTTP token, as per
173: * [RFC7230] Section 3.2.6
174: */
175: public static function maybeQuoteString(string $string): string
176: {
177: return Regex::match('/^' . Regex::HTTP_TOKEN . '$/D', $string)
178: ? $string
179: : '"' . self::escapeQuotedString($string) . '"';
180: }
181:
182: /**
183: * Escape backslashes and double-quote marks in a string as per [RFC7230]
184: * Section 3.2.6
185: */
186: public static function escapeQuotedString(string $string): string
187: {
188: return str_replace(['\\', '"'], ['\\\\', '\"'], $string);
189: }
190:
191: /**
192: * Unescape and remove quotes from a string as per [RFC7230] Section 3.2.6
193: */
194: public static function unquoteString(string $string): string
195: {
196: $string = Regex::replace('/^"(.*)"$/D', '$1', $string, -1, $count);
197: return $count
198: ? Regex::replace('/\\\\(.)/', '$1', $string)
199: : $string;
200: }
201:
202: /**
203: * Merge values into the query string of a request or URI
204: *
205: * @template T of RequestInterface|PsrUriInterface|Stringable|string
206: *
207: * @param T $value
208: * @param mixed[] $data
209: * @param int-mask-of<FormDataFlag::*> $flags
210: * @return (T is RequestInterface|PsrUriInterface ? T : Uri)
211: */
212: public static function mergeQuery(
213: $value,
214: array $data,
215: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
216: ?DateFormatterInterface $dateFormatter = null
217: ) {
218: if ($value instanceof RequestInterface) {
219: $uri = $value->getUri();
220: $return = $value;
221: } elseif ($value instanceof PsrUriInterface) {
222: $return = $uri = $value;
223: } else {
224: $return = $uri = new Uri((string) $value);
225: }
226:
227: /** @todo Replace with `parse_str()` alternative */
228: parse_str($uri->getQuery(), $query);
229: $query = (new FormData(array_replace_recursive($query, $data)))
230: ->getQuery($flags, $dateFormatter);
231:
232: return $return instanceof RequestInterface
233: ? $return->withUri($uri->withQuery($query))
234: : $return->withQuery($query);
235: }
236:
237: /**
238: * Replace the query string of a request or URI with the given values
239: *
240: * @template T of RequestInterface|PsrUriInterface|Stringable|string
241: *
242: * @param T $value
243: * @param mixed[] $data
244: * @param int-mask-of<FormDataFlag::*> $flags
245: * @return (T is RequestInterface|PsrUriInterface ? T : Uri)
246: */
247: public static function replaceQuery(
248: $value,
249: array $data,
250: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
251: ?DateFormatterInterface $dateFormatter = null
252: ) {
253: $query = (new FormData($data))->getQuery($flags, $dateFormatter);
254: if ($value instanceof RequestInterface) {
255: return $value->withUri($value->getUri()->withQuery($query));
256: }
257: if (!$value instanceof PsrUriInterface) {
258: $value = new Uri((string) $value);
259: }
260: return $value->withQuery($query);
261: }
262:
263: /**
264: * Get key-value pairs from a list of arrays with "name" and "value" keys
265: *
266: * @param iterable<array{name:string,value:string}> $items
267: * @return Generator<string,string>
268: */
269: public static function getNameValueGenerator(iterable $items): Generator
270: {
271: foreach ($items as $item) {
272: yield $item['name'] => $item['value'];
273: }
274: }
275: }
276: