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