curlOptions = $options; } public function request(string $method, string $url, array $params = [], array $data = [], array $headers = [], ?string $user = null, ?string $password = null, ?int $timeout = null, ?AuthStrategy $authStrategy = null): Response { $options = $this->options($method, $url, $params, $data, $headers, $user, $password, $timeout, $authStrategy); $this->lastRequest = $options; $this->lastResponse = null; try { if (!$curl = \curl_init()) { throw new EnvironmentException('Unable to initialize cURL'); } if (!\curl_setopt_array($curl, $options)) { throw new EnvironmentException(\curl_error($curl)); } if (!$response = \curl_exec($curl)) { throw new EnvironmentException(\curl_error($curl)); } $parts = \explode("\r\n\r\n", $response, 3); list($head, $body) = ( \preg_match('/\AHTTP\/1.\d 100 Continue\Z/', $parts[0]) || \preg_match('/\AHTTP\/1.\d 200 Connection established\Z/', $parts[0]) || \preg_match('/\AHTTP\/1.\d 200 Tunnel established\Z/', $parts[0]) ) ? array($parts[1], $parts[2]) : array($parts[0], $parts[1]); $statusCode = \curl_getinfo($curl, CURLINFO_HTTP_CODE); $responseHeaders = []; $headerLines = \explode("\r\n", $head); \array_shift($headerLines); foreach ($headerLines as $line) { list($key, $value) = \explode(':', $line, 2); $responseHeaders[$key] = $value; } if (PHP_MAJOR_VERSION < 8) { \curl_close($curl); } if (isset($options[CURLOPT_INFILE]) && \is_resource($options[CURLOPT_INFILE])) { \fclose($options[CURLOPT_INFILE]); } $this->lastResponse = new Response($statusCode, $body, $responseHeaders); return $this->lastResponse; } catch (\ErrorException $e) { if (PHP_MAJOR_VERSION < 8 && isset($curl) && \is_resource($curl)) { \curl_close($curl); } if (isset($options[CURLOPT_INFILE]) && \is_resource($options[CURLOPT_INFILE])) { \fclose($options[CURLOPT_INFILE]); } throw $e; } } public function options(string $method, string $url, array $params = [], array $data = [], array $headers = [], ?string $user = null, ?string $password = null, ?int $timeout = null, ?AuthStrategy $authStrategy = null): array { $timeout = $timeout ?? self::DEFAULT_TIMEOUT; $options = $this->curlOptions + [ CURLOPT_URL => $url, CURLOPT_HEADER => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_INFILESIZE => Null, CURLOPT_HTTPHEADER => [], CURLOPT_TIMEOUT => $timeout, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP ]; foreach ($headers as $key => $value) { $options[CURLOPT_HTTPHEADER][] = "$key: $value"; } if ($user && $password) { $options[CURLOPT_HTTPHEADER][] = 'Authorization: Basic ' . \base64_encode("$user:$password"); } elseif ($authStrategy) { $options[CURLOPT_HTTPHEADER][] = 'Authorization: ' . $authStrategy->getAuthString(); } $query = $this->buildQuery($params); if ($query) { $options[CURLOPT_URL] .= '?' . $query; } $methodName = \strtolower(\trim($method)); // Configure HTTP method-specific options if ($methodName === 'get') { $options[CURLOPT_HTTPGET] = true; } elseif ($methodName === 'head') { $options[CURLOPT_NOBODY] = true; } elseif (\in_array($methodName, ['post', 'put', 'patch'])) { // Handle methods that send data in the request body $this->configureMethodWithData($options, $methodName, $method, $data, $headers); } else { // Handle other HTTP methods (DELETE, etc.) $options[CURLOPT_CUSTOMREQUEST] = \strtoupper($method); } return $options; } /** * Configure cURL options for HTTP methods that send data in the request body * (POST, PUT, PATCH) */ private function configureMethodWithData(array &$options, string $methodName, string $method, array $data, array $headers): void { // Set the appropriate cURL option for the HTTP method if ($methodName === 'post') { $options[CURLOPT_POST] = true; } else { $options[CURLOPT_CUSTOMREQUEST] = \strtoupper($method); } // Configure the request body based on data type if ($this->hasFile($data)) { // Handle multipart/form-data for file uploads [$headers, $body] = $this->buildMultipartOptions($data); $options[CURLOPT_POSTFIELDS] = $body; $options[CURLOPT_HTTPHEADER] = \array_merge($options[CURLOPT_HTTPHEADER], $headers); } elseif (isset($headers['Content-Type']) && $headers['Content-Type'] === 'application/json') { // Handle JSON data $options[CURLOPT_POSTFIELDS] = \json_encode($data); } else { // Handle URL-encoded form data $options[CURLOPT_POSTFIELDS] = $this->buildQuery($data); } } public function buildQuery(?array $params): string { $parts = []; $params = $params ?: []; foreach ($params as $key => $value) { if (\is_array($value)) { foreach ($value as $item) { $parts[] = $this->encodeQueryComponent((string)$key) . '=' . $this->encodeQueryComponent((string)$item); } } else { $parts[] = $this->encodeQueryComponent((string)$key) . '=' . $this->encodeQueryComponent((string)$value); } } return \implode('&', $parts); } /** * Custom encoder for query string components that: * 1. Encodes spaces as '+' (like urlencode) * 2. Preserves unreserved characters including tilde (like rawurlencode) */ private function encodeQueryComponent(string $string): string { // Start with rawurlencode to encode as per RFC 3986 $encoded = \rawurlencode($string); // Convert %20 back to + for query string compatibility $encoded = \str_replace('%20', '+', $encoded); return $encoded; } private function hasFile(array $data): bool { foreach ($data as $value) { if ($value instanceof File) { return true; } } return false; } private function buildMultipartOptions(array $data): array { $boundary = \uniqid('', true); $delimiter = "-------------{$boundary}"; $body = ''; foreach ($data as $key => $value) { if ($value instanceof File) { $contents = $value->getContents(); if ($contents === null) { $chunk = \file_get_contents($value->getFileName()); $filename = \basename($value->getFileName()); } elseif (\is_resource($contents)) { $chunk = ''; while (!\feof($contents)) { $chunk .= \fread($contents, 8096); } $filename = $value->getFileName(); } elseif (\is_string($contents)) { $chunk = $contents; $filename = $value->getFileName(); } else { throw new \InvalidArgumentException('Unsupported content type'); } $headers = ''; $contentType = $value->getContentType(); if ($contentType !== null) { $headers .= "Content-Type: {$contentType}\r\n"; } $body .= \vsprintf("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n%s\r\n%s\r\n", [ $delimiter, $key, $filename, $headers, $chunk, ]); } else { $body .= \vsprintf("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n", [ $delimiter, $key, $value, ]); } } $body .= "--{$delimiter}--\r\n"; return [ [ "Content-Type: multipart/form-data; boundary={$delimiter}", 'Content-Length: ' . \strlen($body), ], $body, ]; } }