HttpClient.php
PHP
Path: src/Http/Client/HttpClient.php
<?php
namespace mini\Http\Client;
use mini\Http\Message\Response;
use mini\Http\Message\Stream;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* PSR-18 compliant HTTP client built on curl
*
* Usage:
* ```php
* $client = new HttpClient();
*
* // PSR-18: Send a PSR-7 request
* $request = new Request('GET', 'https://api.example.com/users');
* $response = $client->sendRequest($request);
*
* // Convenience methods
* $response = $client->get('https://api.example.com/users');
* $response = $client->post('https://api.example.com/users', ['name' => 'John']);
* $response = $client->postJson('https://api.example.com/users', ['name' => 'John']);
*
* // With options
* $client = new HttpClient([
* 'timeout' => 30,
* 'verify_ssl' => true,
* 'follow_redirects' => true,
* 'max_redirects' => 5,
* ]);
* ```
*/
class HttpClient implements ClientInterface
{
/** @var array Default options */
private array $options;
/** @var array Default curl options */
private const DEFAULT_OPTIONS = [
'timeout' => 30,
'connect_timeout' => 10,
'verify_ssl' => true,
'follow_redirects' => true,
'max_redirects' => 5,
'user_agent' => 'Mini/1.0 (PSR-18 HTTP Client)',
'headers' => [],
];
public function __construct(array $options = [])
{
$this->options = array_merge(self::DEFAULT_OPTIONS, $options);
}
/**
* PSR-18: Send a PSR-7 request and return a PSR-7 response
*
* @throws NetworkException On network errors
* @throws RequestException On malformed requests
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
if (!extension_loaded('curl')) {
throw new ClientException('The curl extension is required for HttpClient');
}
$curl = curl_init();
try {
$this->configureCurl($curl, $request);
return $this->executeCurl($curl, $request);
} finally {
curl_close($curl);
}
}
/**
* Configure curl handle from PSR-7 request
*/
private function configureCurl(\CurlHandle $curl, RequestInterface $request): void
{
$url = (string) $request->getUri();
$method = $request->getMethod();
// Basic setup
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => $this->options['timeout'],
CURLOPT_CONNECTTIMEOUT => $this->options['connect_timeout'],
CURLOPT_USERAGENT => $this->options['user_agent'],
CURLOPT_SSL_VERIFYPEER => $this->options['verify_ssl'],
CURLOPT_SSL_VERIFYHOST => $this->options['verify_ssl'] ? 2 : 0,
CURLOPT_FOLLOWLOCATION => $this->options['follow_redirects'],
CURLOPT_MAXREDIRS => $this->options['max_redirects'],
CURLOPT_ENCODING => '', // Accept all encodings
]);
// HTTP method
switch (strtoupper($method)) {
case 'GET':
curl_setopt($curl, CURLOPT_HTTPGET, true);
break;
case 'POST':
curl_setopt($curl, CURLOPT_POST, true);
break;
case 'HEAD':
curl_setopt($curl, CURLOPT_NOBODY, true);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
case 'OPTIONS':
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
break;
default:
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
}
// Request body
$body = $request->getBody();
$bodyContent = (string) $body;
if ($bodyContent !== '' && !in_array(strtoupper($method), ['GET', 'HEAD'])) {
curl_setopt($curl, CURLOPT_POSTFIELDS, $bodyContent);
}
// Headers
$headers = $this->buildHeaders($request);
if (!empty($headers)) {
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
}
// HTTP version
$version = $request->getProtocolVersion();
if ($version === '1.0') {
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
} elseif ($version === '1.1') {
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
} elseif ($version === '2' || $version === '2.0') {
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2);
}
}
/**
* Build headers array for curl
*/
private function buildHeaders(RequestInterface $request): array
{
$headers = [];
// Add default headers
foreach ($this->options['headers'] as $name => $value) {
$headers[] = "$name: $value";
}
// Add request headers (overrides defaults)
foreach ($request->getHeaders() as $name => $values) {
foreach ($values as $value) {
$headers[] = "$name: $value";
}
}
return $headers;
}
/**
* Execute curl and return PSR-7 response
*/
private function executeCurl(\CurlHandle $curl, RequestInterface $request): ResponseInterface
{
$result = curl_exec($curl);
if ($result === false) {
$errno = curl_errno($curl);
$error = curl_error($curl);
// Network errors
if (in_array($errno, [
CURLE_COULDNT_RESOLVE_HOST,
CURLE_COULDNT_CONNECT,
CURLE_OPERATION_TIMEDOUT,
CURLE_SSL_CONNECT_ERROR,
CURLE_GOT_NOTHING,
CURLE_RECV_ERROR,
CURLE_SEND_ERROR,
])) {
throw new NetworkException($request, "Network error: $error", $errno);
}
throw new RequestException($request, "Request failed: $error", $errno);
}
return $this->parseResponse($result, $curl);
}
/**
* Parse curl response into PSR-7 Response
*/
private function parseResponse(string $result, \CurlHandle $curl): ResponseInterface
{
$headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
$statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$headerString = substr($result, 0, $headerSize);
$body = substr($result, $headerSize);
// Parse headers
$headers = [];
$protocolVersion = '1.1';
$reasonPhrase = '';
$lines = explode("\r\n", trim($headerString));
foreach ($lines as $line) {
// Skip empty lines
if ($line === '') {
continue;
}
// Status line
if (str_starts_with($line, 'HTTP/')) {
if (preg_match('#^HTTP/(\d+\.?\d*)\s+(\d+)\s*(.*)$#', $line, $matches)) {
$protocolVersion = $matches[1];
$statusCode = (int) $matches[2];
$reasonPhrase = $matches[3];
}
continue;
}
// Header line
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$name = trim($parts[0]);
$value = trim($parts[1]);
$headers[$name][] = $value;
}
}
return new Response(
body: Stream::cast($body),
headers: $headers,
statusCode: $statusCode,
reasonPhrase: $reasonPhrase,
protocolVersion: $protocolVersion
);
}
// =========================================================================
// Convenience methods
// =========================================================================
/**
* Send a GET request
*/
public function get(string $url, array $headers = []): ResponseInterface
{
return $this->request('GET', $url, null, $headers);
}
/**
* Send a POST request with form data
*/
public function post(string $url, array|string $data = [], array $headers = []): ResponseInterface
{
if (is_array($data)) {
$data = http_build_query($data);
$headers['Content-Type'] ??= 'application/x-www-form-urlencoded';
}
return $this->request('POST', $url, $data, $headers);
}
/**
* Send a POST request with JSON body
*/
public function postJson(string $url, mixed $data, array $headers = []): ResponseInterface
{
$headers['Content-Type'] = 'application/json';
return $this->request('POST', $url, json_encode($data), $headers);
}
/**
* Send a PUT request
*/
public function put(string $url, array|string $data = [], array $headers = []): ResponseInterface
{
if (is_array($data)) {
$data = http_build_query($data);
$headers['Content-Type'] ??= 'application/x-www-form-urlencoded';
}
return $this->request('PUT', $url, $data, $headers);
}
/**
* Send a PUT request with JSON body
*/
public function putJson(string $url, mixed $data, array $headers = []): ResponseInterface
{
$headers['Content-Type'] = 'application/json';
return $this->request('PUT', $url, json_encode($data), $headers);
}
/**
* Send a PATCH request
*/
public function patch(string $url, array|string $data = [], array $headers = []): ResponseInterface
{
if (is_array($data)) {
$data = http_build_query($data);
$headers['Content-Type'] ??= 'application/x-www-form-urlencoded';
}
return $this->request('PATCH', $url, $data, $headers);
}
/**
* Send a PATCH request with JSON body
*/
public function patchJson(string $url, mixed $data, array $headers = []): ResponseInterface
{
$headers['Content-Type'] = 'application/json';
return $this->request('PATCH', $url, json_encode($data), $headers);
}
/**
* Send a DELETE request
*/
public function delete(string $url, array $headers = []): ResponseInterface
{
return $this->request('DELETE', $url, null, $headers);
}
/**
* Send a HEAD request
*/
public function head(string $url, array $headers = []): ResponseInterface
{
return $this->request('HEAD', $url, null, $headers);
}
/**
* Send a request with custom method
*
* @param string $method HTTP method
* @param string $url Target URL
* @param string|StreamInterface|null $body Request body (string or stream)
* @param array $headers Request headers
*/
public function request(string $method, string $url, string|\Psr\Http\Message\StreamInterface|null $body = null, array $headers = []): ResponseInterface
{
// Use Request::create() which properly parses full URLs into URI components
$request = \mini\Http\Message\Request::create($method, $url);
// Add headers
foreach ($headers as $name => $value) {
$request = $request->withHeader($name, $value);
}
// Add body if provided
if ($body !== null) {
if ($body instanceof \Psr\Http\Message\StreamInterface) {
$request = $request->withBody($body);
} elseif ($body !== '') {
$request = $request->withBody(Stream::cast($body));
}
}
return $this->sendRequest($request);
}
}