friendship ended with social-app. php is my new best friend
at main 15 kB view raw
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}