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: | |
31: | |
32: | final class HttpUtil extends AbstractUtility implements |
33: | HasFormDataFlag, |
34: | HasHttpHeader, |
35: | HasHttpRegex, |
36: | HasMediaType, |
37: | HasRequestMethod |
38: | { |
39: | |
40: | |
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: | |
57: | |
58: | |
59: | |
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: | |
80: | |
81: | |
82: | |
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: | |
96: | |
97: | |
98: | |
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: | |
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: | |
122: | |
123: | |
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: | |
140: | |
141: | |
142: | |
143: | |
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: | |
162: | |
163: | |
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: | |
196: | |
197: | |
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: | |
214: | return $last === '' && $merged !== '' |
215: | ? substr($merged, 0, -1) |
216: | : $merged; |
217: | } |
218: | |
219: | |
220: | |
221: | |
222: | |
223: | |
224: | public static function isRequestMethod(string $method): bool |
225: | { |
226: | return Reflect::hasConstantWithValue(HasRequestMethod::class, $method); |
227: | } |
228: | |
229: | |
230: | |
231: | |
232: | |
233: | |
234: | |
235: | |
236: | |
237: | |
238: | public static function isAuthorityForm(string $target): bool |
239: | { |
240: | return (bool) Regex::match(self::AUTHORITY_FORM_REGEX, $target); |
241: | } |
242: | |
243: | |
244: | |
245: | |
246: | |
247: | |
248: | |
249: | |
250: | public static function mediaTypeIs(string $type, string $mimeType): bool |
251: | { |
252: | |
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: | |
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: | |
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: | |
282: | |
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: | |
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: | |
308: | |
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: | |
319: | |
320: | |
321: | public static function quoteString(string $string): string |
322: | { |
323: | return '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $string) . '"'; |
324: | } |
325: | |
326: | |
327: | |
328: | |
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: | |
340: | |
341: | |
342: | |
343: | |
344: | |
345: | |
346: | |
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: | |
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: | |
366: | |
367: | |
368: | |
369: | |
370: | |
371: | |
372: | |
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: | |
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: | |
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: | |
425: | |
426: | |
427: | |
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: | |