friendship ended with social-app. php is my new best friend
1<?php
2
3declare(strict_types=1);
4
5namespace Fetch\Http;
6
7use Fetch\Enum\ContentType;
8use Fetch\Enum\Method;
9use Fetch\Traits\RequestImmutabilityTrait;
10use GuzzleHttp\Psr7\Request as BaseRequest;
11use GuzzleHttp\Psr7\Uri;
12use GuzzleHttp\Psr7\Utils;
13use InvalidArgumentException;
14use Psr\Http\Message\RequestInterface;
15use Psr\Http\Message\UriInterface;
16
17class Request extends BaseRequest implements RequestInterface
18{
19 use RequestImmutabilityTrait;
20
21 /**
22 * The custom request target, if set.
23 */
24 protected ?string $customRequestTarget = null;
25
26 /**
27 * Create a new Request instance.
28 */
29 public function __construct(
30 string|Method $method,
31 string|UriInterface $uri,
32 array $headers = [],
33 $body = null,
34 string $version = '1.1',
35 ?string $requestTarget = null
36 ) {
37 // Normalize the method
38 $methodValue = $method instanceof Method ? $method->value : strtoupper($method);
39
40 // Convert string URI to UriInterface if needed
41 $uriObject = is_string($uri) ? new Uri($uri) : $uri;
42
43 // Initialize with parent constructor
44 parent::__construct($methodValue, $uriObject, $headers, $body, $version);
45
46 // Store custom request target if provided
47 if ($requestTarget !== null) {
48 $this->customRequestTarget = $requestTarget;
49 }
50 }
51
52 /**
53 * Create a new Request instance with a JSON body.
54 */
55 public static function json(
56 string|Method $method,
57 string|UriInterface $uri,
58 array $data,
59 array $headers = []
60 ): static {
61 // Normalize the method
62 $methodValue = $method instanceof Method ? $method->value : strtoupper($method);
63
64 // Prepare the JSON body
65 $body = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
66
67 if ($body === false) {
68 throw new InvalidArgumentException('Unable to encode data to JSON: '.json_last_error_msg());
69 }
70
71 // Add the Content-Type header if not already present
72 $headers['Content-Type'] = ContentType::JSON->value;
73
74 // Add Content-Length if not already set
75 if (! isset($headers['Content-Length'])) {
76 $headers['Content-Length'] = (string) strlen($body);
77 }
78
79 return new static($methodValue, $uri, $headers, $body);
80 }
81
82 /**
83 * Create a new Request instance with form parameters.
84 */
85 public static function form(
86 string|Method $method,
87 string|UriInterface $uri,
88 array $formParams,
89 array $headers = []
90 ): static {
91 // Normalize the method
92 $methodValue = $method instanceof Method ? $method->value : strtoupper($method);
93
94 // Prepare the form body
95 $body = http_build_query($formParams);
96
97 // Add the Content-Type header if not already present
98 $headers['Content-Type'] = ContentType::FORM_URLENCODED->value;
99
100 // Add Content-Length if not already set
101 if (! isset($headers['Content-Length'])) {
102 $headers['Content-Length'] = (string) strlen($body);
103 }
104
105 return new static($methodValue, $uri, $headers, $body);
106 }
107
108 /**
109 * Create a new Request instance with multipart form data.
110 */
111 public static function multipart(
112 string|Method $method,
113 string|UriInterface $uri,
114 array $multipart,
115 array $headers = []
116 ): static {
117 // Normalize the method
118 $methodValue = $method instanceof Method ? $method->value : strtoupper($method);
119
120 // Generate a boundary
121 $boundary = uniqid('', true);
122
123 // Build the multipart body
124 $body = '';
125 foreach ($multipart as $part) {
126 $body .= "--{$boundary}\r\n";
127
128 // Add part headers
129 if (isset($part['headers']) && is_array($part['headers'])) {
130 foreach ($part['headers'] as $name => $value) {
131 $body .= "{$name}: {$value}\r\n";
132 }
133 }
134
135 // Add Content-Disposition
136 $body .= 'Content-Disposition: form-data; name="'.$part['name'].'"';
137
138 // Add filename if present
139 if (isset($part['filename'])) {
140 $body .= '; filename="'.$part['filename'].'"';
141 }
142
143 $body .= "\r\n\r\n";
144
145 // Add contents
146 $body .= $part['contents']."\r\n";
147 }
148
149 // Add the final boundary
150 $body .= "--{$boundary}--\r\n";
151
152 // Set the Content-Type header with the boundary
153 $headers['Content-Type'] = ContentType::MULTIPART->value.'; boundary='.$boundary;
154
155 // Add Content-Length if not already set
156 if (! isset($headers['Content-Length'])) {
157 $headers['Content-Length'] = (string) strlen($body);
158 }
159
160 return new static($methodValue, $uri, $headers, $body);
161 }
162
163 /**
164 * Create a new GET request.
165 */
166 public static function get(string|UriInterface $uri, array $headers = []): static
167 {
168 return new static(Method::GET->value, $uri, $headers);
169 }
170
171 /**
172 * Create a new POST request.
173 */
174 public static function post(
175 string|UriInterface $uri,
176 $body = null,
177 array $headers = [],
178 ContentType|string|null $contentType = null
179 ): static {
180 if ($contentType) {
181 $contentTypeValue = $contentType instanceof ContentType ? $contentType->value : $contentType;
182 $headers['Content-Type'] = $contentTypeValue;
183 }
184
185 return new static(Method::POST->value, $uri, $headers, $body);
186 }
187
188 /**
189 * Create a new PUT request.
190 */
191 public static function put(
192 string|UriInterface $uri,
193 $body = null,
194 array $headers = [],
195 ContentType|string|null $contentType = null
196 ): static {
197 if ($contentType) {
198 $contentTypeValue = $contentType instanceof ContentType ? $contentType->value : $contentType;
199 $headers['Content-Type'] = $contentTypeValue;
200 }
201
202 return new static(Method::PUT->value, $uri, $headers, $body);
203 }
204
205 /**
206 * Create a new PATCH request.
207 */
208 public static function patch(
209 string|UriInterface $uri,
210 $body = null,
211 array $headers = [],
212 ContentType|string|null $contentType = null
213 ): static {
214 if ($contentType) {
215 $contentTypeValue = $contentType instanceof ContentType ? $contentType->value : $contentType;
216 $headers['Content-Type'] = $contentTypeValue;
217 }
218
219 return new static(Method::PATCH->value, $uri, $headers, $body);
220 }
221
222 /**
223 * Create a new DELETE request.
224 */
225 public static function delete(
226 string|UriInterface $uri,
227 $body = null,
228 array $headers = [],
229 ContentType|string|null $contentType = null
230 ): static {
231 if ($contentType) {
232 $contentTypeValue = $contentType instanceof ContentType ? $contentType->value : $contentType;
233 $headers['Content-Type'] = $contentTypeValue;
234 }
235
236 return new static(Method::DELETE->value, $uri, $headers, $body);
237 }
238
239 /**
240 * Create a new HEAD request.
241 */
242 public static function head(string|UriInterface $uri, array $headers = []): static
243 {
244 return new static(Method::HEAD->value, $uri, $headers);
245 }
246
247 /**
248 * Create a new OPTIONS request.
249 */
250 public static function options(string|UriInterface $uri, array $headers = []): static
251 {
252 return new static(Method::OPTIONS->value, $uri, $headers);
253 }
254
255 /**
256 * Override getRequestTarget to use our custom target if set.
257 */
258 public function getRequestTarget(): string
259 {
260 if ($this->customRequestTarget !== null) {
261 return $this->customRequestTarget;
262 }
263
264 return parent::getRequestTarget();
265 }
266
267 /**
268 * Override withRequestTarget to store the custom target.
269 */
270 public function withRequestTarget($requestTarget): static
271 {
272 $new = clone $this;
273 $new->customRequestTarget = $requestTarget;
274
275 return $new;
276 }
277
278 /**
279 * Check if the request method supports a request body.
280 */
281 public function supportsRequestBody(): bool
282 {
283 try {
284 $method = Method::fromString($this->getMethod());
285
286 return $method->supportsRequestBody();
287 } catch (\ValueError $e) {
288 // Unknown method, assume it might support a body
289 return true;
290 }
291 }
292
293 /**
294 * Get the method as an enum.
295 */
296 public function getMethodEnum(): ?Method
297 {
298 return Method::tryFromString($this->getMethod());
299 }
300
301 /**
302 * Get the content type from the headers.
303 */
304 public function getContentTypeEnum(): ?ContentType
305 {
306 if (! $this->hasHeader('Content-Type')) {
307 return null;
308 }
309
310 $contentType = $this->getHeaderLine('Content-Type');
311
312 // Strip parameters like charset
313 if (($pos = strpos($contentType, ';')) !== false) {
314 $contentType = trim(substr($contentType, 0, $pos));
315 }
316
317 return ContentType::tryFromString($contentType);
318 }
319
320 /**
321 * Check if the request has JSON content.
322 */
323 public function hasJsonContent(): bool
324 {
325 $contentType = $this->getContentTypeEnum();
326
327 return $contentType === ContentType::JSON;
328 }
329
330 /**
331 * Check if the request has form content.
332 */
333 public function hasFormContent(): bool
334 {
335 $contentType = $this->getContentTypeEnum();
336
337 return $contentType === ContentType::FORM_URLENCODED;
338 }
339
340 /**
341 * Check if the request has multipart content.
342 */
343 public function hasMultipartContent(): bool
344 {
345 $contentType = $this->getContentTypeEnum();
346
347 return $contentType === ContentType::MULTIPART;
348 }
349
350 /**
351 * Check if the request has text content.
352 */
353 public function hasTextContent(): bool
354 {
355 $contentType = $this->getContentTypeEnum();
356
357 return $contentType && $contentType->isText();
358 }
359
360 /**
361 * Get the request body as a string.
362 */
363 public function getBodyAsString(): string
364 {
365 $body = $this->getBody();
366 $body->rewind();
367
368 return $body->getContents();
369 }
370
371 /**
372 * Get the request body as JSON.
373 *
374 * @throws InvalidArgumentException If the body is not valid JSON
375 */
376 public function getBodyAsJson(bool $assoc = true, int $depth = 512, int $options = 0): mixed
377 {
378 $body = $this->getBodyAsString();
379
380 if (empty($body)) {
381 return $assoc ? [] : new \stdClass;
382 }
383
384 $data = json_decode($body, $assoc, $depth, $options);
385
386 if (json_last_error() !== JSON_ERROR_NONE) {
387 throw new InvalidArgumentException('Invalid JSON: '.json_last_error_msg());
388 }
389
390 return $data;
391 }
392
393 /**
394 * Get the request body as form parameters.
395 *
396 * @return array<string, mixed>
397 */
398 public function getBodyAsFormParams(): array
399 {
400 $body = $this->getBodyAsString();
401
402 if (empty($body)) {
403 return [];
404 }
405
406 $params = [];
407 parse_str($body, $params);
408
409 return $params;
410 }
411
412 /**
413 * Set the request body.
414 */
415 public function withBody($body): static
416 {
417 if (is_string($body)) {
418 $body = Utils::streamFor($body);
419 }
420
421 return $this->toStatic(parent::withBody($body));
422 }
423
424 /**
425 * Set the content type of the request.
426 */
427 public function withContentType(ContentType|string $contentType): static
428 {
429 $value = $contentType instanceof ContentType ? $contentType->value : $contentType;
430
431 return $this->withHeader('Content-Type', $value);
432 }
433
434 /**
435 * Set a query parameter on the request URI.
436 */
437 public function withQueryParam(string $name, string|int|float|bool|null $value): static
438 {
439 $uri = $this->getUri();
440 $query = $uri->getQuery();
441
442 $params = [];
443 if (! empty($query)) {
444 parse_str($query, $params);
445 }
446
447 // Add or update the parameter
448 $params[$name] = $value;
449
450 // Build the new query string
451 $newQuery = http_build_query($params);
452
453 // Create a new URI with the updated query
454 $newUri = $uri->withQuery($newQuery);
455
456 // Return a new request with the updated URI
457 return $this->withUri($newUri);
458 }
459
460 /**
461 * Set multiple query parameters on the request URI.
462 */
463 public function withQueryParams(array $params): static
464 {
465 $uri = $this->getUri();
466 $query = $uri->getQuery();
467
468 $existingParams = [];
469 if (! empty($query)) {
470 parse_str($query, $existingParams);
471 }
472
473 // Merge the existing parameters with the new ones
474 $mergedParams = array_merge($existingParams, $params);
475
476 // Build the new query string
477 $newQuery = http_build_query($mergedParams);
478
479 // Create a new URI with the updated query
480 $newUri = $uri->withQuery($newQuery);
481
482 // Return a new request with the updated URI
483 return $this->withUri($newUri);
484 }
485
486 /**
487 * Set an authorization header with a bearer token.
488 */
489 public function withBearerToken(string $token): static
490 {
491 return $this->withHeader('Authorization', 'Bearer '.$token);
492 }
493
494 /**
495 * Set a basic authentication header.
496 */
497 public function withBasicAuth(string $username, string $password): static
498 {
499 $auth = base64_encode("$username:$password");
500
501 return $this->withHeader('Authorization', 'Basic '.$auth);
502 }
503
504 /**
505 * Set a JSON body on the request.
506 *
507 * @throws InvalidArgumentException If the data cannot be encoded as JSON
508 */
509 public function withJsonBody(array $data, int $options = 0): static
510 {
511 $json = json_encode($data, $options);
512
513 if ($json === false) {
514 throw new InvalidArgumentException('Unable to encode data to JSON: '.json_last_error_msg());
515 }
516
517 $request = $this->withBody(Utils::streamFor($json));
518
519 // Add or update Content-Type header
520 return $request->withContentType(ContentType::JSON);
521 }
522
523 /**
524 * Set a form body on the request.
525 */
526 public function withFormBody(array $data): static
527 {
528 $body = http_build_query($data);
529 $request = $this->withBody(Utils::streamFor($body));
530
531 // Add or update Content-Type header
532 return $request->withContentType(ContentType::FORM_URLENCODED);
533 }
534}