1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\MessageInterface;
6: use Psr\Http\Message\RequestInterface;
7: use Psr\Http\Message\StreamInterface;
8: use Psr\Http\Message\UriInterface as PsrUriInterface;
9: use Salient\Contract\Core\Arrayable;
10: use Salient\Contract\Http\HttpHeader;
11: use Salient\Contract\Http\HttpRequestInterface;
12: use Salient\Contract\Http\HttpRequestMethod as Method;
13: use Salient\Contract\Http\MimeType;
14: use Salient\Core\Concern\ImmutableTrait;
15: use Salient\Utility\Exception\InvalidArgumentTypeException;
16: use Salient\Utility\Regex;
17: use InvalidArgumentException;
18: use Stringable;
19:
20: /**
21: * A PSR-7 request (outgoing, client-side)
22: *
23: * @api
24: */
25: class HttpRequest extends AbstractHttpMessage implements HttpRequestInterface
26: {
27: use ImmutableTrait;
28:
29: private const TOKEN = '/^[-0-9a-z!#$%&\'*+.^_`|~]++$/iD';
30:
31: protected string $Method;
32: protected ?string $RequestTarget;
33: protected Uri $Uri;
34:
35: /**
36: * @param PsrUriInterface|Stringable|string $uri
37: * @param StreamInterface|resource|string|null $body
38: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|null $headers
39: */
40: public function __construct(
41: string $method,
42: $uri,
43: $body = null,
44: $headers = null,
45: ?string $requestTarget = null,
46: string $version = '1.1'
47: ) {
48: $this->Method = $this->filterMethod($method);
49: $this->RequestTarget = $this->filterRequestTarget($requestTarget);
50: $this->Uri = $this->filterUri($uri);
51:
52: parent::__construct($body, $headers, $version);
53:
54: if ($this->Headers->getHeaderLine(HttpHeader::HOST) !== '') {
55: return;
56: }
57: $host = $this->getUriHost();
58: if ($host === '') {
59: return;
60: }
61: $this->Headers = $this->Headers->set(HttpHeader::HOST, $host);
62: }
63:
64: /**
65: * @inheritDoc
66: */
67: public static function fromPsr7(MessageInterface $message): HttpRequest
68: {
69: if ($message instanceof HttpRequest) {
70: return $message;
71: }
72:
73: if (!$message instanceof RequestInterface) {
74: throw new InvalidArgumentTypeException(1, 'message', RequestInterface::class, $message);
75: }
76:
77: return new self(
78: $message->getMethod(),
79: $message->getUri(),
80: $message->getBody(),
81: $message->getHeaders(),
82: $message->getRequestTarget(),
83: $message->getProtocolVersion(),
84: );
85: }
86:
87: /**
88: * @inheritDoc
89: */
90: public function getMethod(): string
91: {
92: return $this->Method;
93: }
94:
95: /**
96: * @inheritDoc
97: */
98: public function getRequestTarget(): string
99: {
100: if ($this->RequestTarget !== null) {
101: return $this->RequestTarget;
102: }
103:
104: $target = $this->Uri->getPath();
105: if ($target === '') {
106: $target = '/';
107: }
108:
109: $query = $this->Uri->toParts()['query'] ?? null;
110: if ($query !== null) {
111: return "{$target}?{$query}";
112: }
113:
114: return $target;
115: }
116:
117: /**
118: * @inheritDoc
119: */
120: public function getUri(): Uri
121: {
122: return $this->Uri;
123: }
124:
125: /**
126: * @inheritDoc
127: */
128: public function withRequestTarget(string $requestTarget): RequestInterface
129: {
130: return $this->with('RequestTarget', $this->filterRequestTarget($requestTarget));
131: }
132:
133: /**
134: * @inheritDoc
135: */
136: public function withMethod(string $method): RequestInterface
137: {
138: return $this->with('Method', $this->filterMethod($method));
139: }
140:
141: /**
142: * @inheritDoc
143: */
144: public function withUri(PsrUriInterface $uri, bool $preserveHost = false): RequestInterface
145: {
146: if ((string) $uri === (string) $this->Uri) {
147: $instance = $this;
148: } else {
149: $instance = $this->with('Uri', $this->filterUri($uri));
150: }
151:
152: if (
153: $preserveHost
154: && $instance->Headers->getHeaderLine(HttpHeader::HOST) !== ''
155: ) {
156: return $instance;
157: }
158:
159: $host = $instance->getUriHost();
160: if ($host === '') {
161: return $instance;
162: }
163: return $instance->withHeader(HttpHeader::HOST, $host);
164: }
165:
166: /**
167: * @return array{method:string,url:string,httpVersion:string,cookies:array<array{name:string,value:string,path?:string,domain?:string,expires?:string,httpOnly?:bool,secure?:bool}>,headers:array<array{name:string,value:string}>,queryString:array<array{name:string,value:string}>,postData?:array{mimeType:string,params:array{},text:string},headersSize:int,bodySize:int}
168: */
169: public function jsonSerialize(): array
170: {
171: $request = parent::jsonSerialize();
172:
173: if (
174: $request['bodySize'] === -1
175: || $request['bodySize'] > 0
176: || ([
177: Method::POST => true,
178: Method::PUT => true,
179: Method::PATCH => true,
180: Method::DELETE => true,
181: ][$this->Method] ?? false)
182: ) {
183: $mediaType = $this->Headers->getHeaderValues(HttpHeader::CONTENT_TYPE);
184: $mediaType = count($mediaType) === 1 ? $mediaType[0] : '';
185: $body = (string) $this->Body;
186: $postData = [
187: 'postData' => [
188: 'mimeType' => $mediaType,
189: 'params' => HttpUtil::mediaTypeIs($mediaType, MimeType::FORM)
190: ? $this->splitQuery($body)
191: : [],
192: 'text' => $body,
193: ],
194: ];
195: } else {
196: $postData = [];
197: }
198:
199: return [
200: 'method' => $this->Method,
201: 'url' => (string) $this->Uri,
202: 'httpVersion' => $request['httpVersion'],
203: 'cookies' => $request['cookies'],
204: 'headers' => $request['headers'],
205: 'queryString' => $this->splitQuery($this->Uri->getQuery()),
206: ] + $postData + $request;
207: }
208:
209: /**
210: * @inheritDoc
211: */
212: protected function getStartLine(): string
213: {
214: return sprintf(
215: '%s %s HTTP/%s',
216: $this->Method,
217: $this->getRequestTarget(),
218: $this->ProtocolVersion,
219: );
220: }
221:
222: private function getUriHost(): string
223: {
224: $host = $this->Uri->getHost();
225: if ($host === '') {
226: return '';
227: }
228:
229: $port = $this->Uri->getPort();
230: if ($port !== null) {
231: $host .= ':' . $port;
232: }
233:
234: return $host;
235: }
236:
237: private function filterMethod(string $method): string
238: {
239: if (!Regex::match(self::TOKEN, $method)) {
240: throw new InvalidArgumentException(
241: sprintf('Invalid HTTP method: %s', $method)
242: );
243: }
244: return $method;
245: }
246:
247: /**
248: * Validate a request target as per [RFC7230] Section 5.3
249: */
250: private function filterRequestTarget(?string $requestTarget): ?string
251: {
252: if ($requestTarget === null || $requestTarget === '') {
253: return null;
254: }
255:
256: // "asterisk-form"
257: if ($requestTarget === '*') {
258: return $requestTarget;
259: }
260:
261: // "authority-form"
262: if (Uri::isAuthorityForm($requestTarget)) {
263: return $requestTarget;
264: }
265:
266: $parts = Uri::parse($requestTarget);
267: if ($parts === false) {
268: throw new InvalidArgumentException(
269: sprintf('Invalid request target: %s', $requestTarget)
270: );
271: }
272:
273: // "absolute-form"
274: if (isset($parts['scheme'])) {
275: return $requestTarget;
276: }
277:
278: // "origin-form"
279: $invalid = array_diff_key($parts, array_flip(['path', 'query']));
280: if (!$invalid) {
281: return $requestTarget;
282: }
283: throw new InvalidArgumentException(
284: sprintf('origin-form of request-target cannot have URI components other than path, query: %s', $requestTarget)
285: );
286: }
287:
288: /**
289: * @param PsrUriInterface|Stringable|string $uri
290: */
291: private function filterUri($uri): Uri
292: {
293: // `Psr\Http\Message\UriInterface` makes no distinction between empty
294: // and undefined URI components, but `/path?` and `/path` are not
295: // necessarily equivalent, so URIs are always converted to instances of
296: // `Salient\Http\Uri`, which surfaces empty and undefined queries via
297: // `Uri::toParts()` as `""` and `null` respectively
298: return Uri::from($uri);
299: }
300:
301: /**
302: * @return array<array{name:string,value:string}>
303: */
304: private function splitQuery(string $query): array
305: {
306: if ($query === '') {
307: return [];
308: }
309: foreach (explode('&', $query) as $param) {
310: $param = explode('=', $param, 2);
311: $params[] = [
312: 'name' => $param[0],
313: 'value' => $param[1] ?? '',
314: ];
315: }
316: return $params;
317: }
318: }
319: