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\Core\MimeType;
9: use Salient\Contract\Http\FormDataFlag;
10: use Salient\Contract\Http\HttpRequestMethod;
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: * @var array<string,string>
31: *
32: * @link https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml
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: return $last === '' && $merged !== ''
152: ? substr($merged, 0, -1)
153: : $merged;
154: }
155:
156: /**
157: * Get a product identifier suitable for User-Agent and Server headers as
158: * per [RFC7231] Section 5.5.3
159: */
160: public static function getProduct(): string
161: {
162: return sprintf(
163: '%s/%s php/%s',
164: str_replace('/', '~', Package::name()),
165: Package::version(true, true),
166: \PHP_VERSION,
167: );
168: }
169:
170: /**
171: * Escape and quote a string unless it is a valid HTTP token, as per
172: * [RFC7230] Section 3.2.6
173: */
174: public static function maybeQuoteString(string $string): string
175: {
176: return Regex::match('/^' . Regex::HTTP_TOKEN . '$/D', $string)
177: ? $string
178: : '"' . self::escapeQuotedString($string) . '"';
179: }
180:
181: /**
182: * Escape backslashes and double-quote marks in a string as per [RFC7230]
183: * Section 3.2.6
184: */
185: public static function escapeQuotedString(string $string): string
186: {
187: return str_replace(['\\', '"'], ['\\\\', '\"'], $string);
188: }
189:
190: /**
191: * Unescape and remove quotes from a string as per [RFC7230] Section 3.2.6
192: */
193: public static function unquoteString(string $string): string
194: {
195: $string = Regex::replace('/^"(.*)"$/D', '$1', $string, -1, $count);
196: return $count
197: ? Regex::replace('/\\\\(.)/', '$1', $string)
198: : $string;
199: }
200:
201: /**
202: * Merge values into the query string of a request or URI
203: *
204: * @template T of RequestInterface|PsrUriInterface|Stringable|string
205: *
206: * @param T $value
207: * @param mixed[] $data
208: * @param int-mask-of<FormDataFlag::*> $flags
209: * @return (T is RequestInterface|PsrUriInterface ? T : Uri)
210: */
211: public static function mergeQuery(
212: $value,
213: array $data,
214: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
215: ?DateFormatterInterface $dateFormatter = null
216: ) {
217: $uri = $value instanceof RequestInterface
218: ? $value->getUri()
219: : ($value instanceof PsrUriInterface
220: ? $value
221: : new Uri((string) $value));
222: /** @todo Replace with `parse_str()` alternative */
223: parse_str($uri->getQuery(), $query);
224: $query = (new FormData(array_replace_recursive($query, $data)))
225: ->getQuery($flags, $dateFormatter);
226: if ($value instanceof RequestInterface) {
227: return $value->withUri($uri->withQuery($query));
228: }
229: return $uri->withQuery($query);
230: }
231:
232: /**
233: * Replace the query string of a request or URI with the given values
234: *
235: * @template T of RequestInterface|PsrUriInterface|Stringable|string
236: *
237: * @param T $value
238: * @param mixed[] $data
239: * @param int-mask-of<FormDataFlag::*> $flags
240: * @return (T is RequestInterface|PsrUriInterface ? T : Uri)
241: */
242: public static function replaceQuery(
243: $value,
244: array $data,
245: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
246: ?DateFormatterInterface $dateFormatter = null
247: ) {
248: $query = (new FormData($data))->getQuery($flags, $dateFormatter);
249: if ($value instanceof RequestInterface) {
250: return $value->withUri($value->getUri()->withQuery($query));
251: }
252: if (!$value instanceof PsrUriInterface) {
253: $value = new Uri((string) $value);
254: }
255: return $value->withQuery($query);
256: }
257:
258: /**
259: * Get key-value pairs from a list of arrays with "name" and "value" keys
260: *
261: * @param iterable<array{name:string,value:string}> $items
262: * @return Generator<string,string>
263: */
264: public static function getNameValueGenerator(iterable $items): Generator
265: {
266: foreach ($items as $item) {
267: yield $item['name'] => $item['value'];
268: }
269: }
270: }
271: