While the Instructor HTTP client API provides built-in support for popular HTTP client libraries (Guzzle, Symfony, and Laravel), there may be cases where you need to integrate with other HTTP client libraries or create specialized implementations. This chapter covers how to create and use custom HTTP client drivers.

Creating Custom Drivers

Creating a custom HTTP client driver involves implementing the CanHandleHttpRequest interface and optionally the CanHandleRequestPool interface for pool support.

Implementing the CanHandleHttpRequest Interface

The CanHandleHttpRequest interface requires implementing a single method:

interface CanHandleHttpRequest
{
    public function handle(HttpClientRequest $request): HttpClientResponse;
}

Here’s a template for creating a custom HTTP client driver:

<?php

namespace YourNamespace\Http\Drivers;

use Cognesy\Polyglot\Http\Contracts\CanHandleHttpRequest;
use Cognesy\Polyglot\Http\Contracts\HttpClientResponse;
use Cognesy\Polyglot\Http\Data\HttpClientConfig;
use Cognesy\Polyglot\Http\Data\HttpClientRequest;
use Cognesy\Polyglot\Http\Events\HttpRequestFailed;
use Cognesy\Polyglot\Http\Events\HttpRequestSent;
use Cognesy\Polyglot\Http\Events\HttpResponseReceived;
use Cognesy\Polyglot\Http\Exceptions\RequestException;
use Cognesy\Utils\Events\EventDispatcher;
use Exception;

class CustomHttpDriver implements CanHandleHttpRequest
{
    /**
     * Your custom HTTP client instance
     */
    private $yourHttpClient;

    /**
     * Constructor
     */
    public function __construct(
        protected HttpClientConfig $config,
        protected ?EventDispatcher $events = null,
    ) {
        $this->events = $events ?? new EventDispatcher();

        // Initialize your HTTP client with the configuration
        $this->yourHttpClient = $this->createYourHttpClient();
    }

    /**
     * Handle an HTTP request
     */
    public function handle(HttpClientRequest $request): HttpClientResponse
    {
        $url = $request->url();
        $headers = $request->headers();
        $body = $request->body()->toString();
        $method = $request->method();
        $streaming = $request->isStreamed();

        // Dispatch event before sending request
        $this->events->dispatch(new HttpRequestSent(
            $url,
            $method,
            $headers,
            $request->body()->toArray()
        ));

        try {
            // Use your HTTP client to make the request
            $response = $this->yourHttpClient->send($method, $url, [
                'headers' => $headers,
                'body' => $body,
                'timeout' => $this->config->requestTimeout,
                'connect_timeout' => $this->config->connectTimeout,
                'stream' => $streaming,
                // Other options relevant to your client...
            ]);

            // Dispatch event for successful response
            $this->events->dispatch(new HttpResponseReceived($response->getStatusCode()));

            // Return the response wrapped in your adapter
            return new YourHttpClientResponse($response, $streaming);

        } catch (Exception $e) {
            // Dispatch event for failed request
            $this->events->dispatch(new HttpRequestFailed(
                $url,
                $method,
                $headers,
                $request->body()->toArray(),
                $e->getMessage()
            ));

            // Wrap the exception
            throw new RequestException($e);
        }
    }

    /**
     * Create your HTTP client instance
     */
    private function createYourHttpClient()
    {
        // Initialize your HTTP client with appropriate configuration
        return new YourHttpClient([
            'connect_timeout' => $this->config->connectTimeout,
            'timeout' => $this->config->requestTimeout,
            'idle_timeout' => $this->config->idleTimeout,
            // Other options...
        ]);
    }
}

Creating a Response Adapter

You also need to create a response adapter that implements the HttpClientResponse interface:

<?php

namespace YourNamespace\Http\Adapters;

use Cognesy\Polyglot\Http\Contracts\HttpClientResponse;
use Generator;

class YourHttpClientResponse implements HttpClientResponse
{
    /**
     * Constructor
     */
    public function __construct(
        private $yourResponse,
        private bool $streaming = false
    ) {}

    /**
     * Get the response status code
     */
    public function statusCode(): int
    {
        return $this->yourResponse->getStatusCode();
    }

    /**
     * Get the response headers
     */
    public function headers(): array
    {
        return $this->yourResponse->getHeaders();
    }

    /**
     * Get the response body
     */
    public function body(): string
    {
        return $this->yourResponse->getBody();
    }

    /**
     * Stream the response body
     */
    public function stream(int $chunkSize = 1): Generator
    {
        if (!$this->streaming) {
            // For non-streaming responses, just yield the entire body
            yield $this->body();
            return;
        }

        // For streaming responses, yield chunks
        $stream = $this->yourResponse->getStream();

        while (!$stream->eof()) {
            yield $stream->read($chunkSize);
        }
    }
}

Using Your Custom Driver

Once you’ve implemented your custom driver, you can use it with the HttpClient:

use Cognesy\Polyglot\Http\HttpClient;
use Cognesy\Polyglot\Http\Data\HttpClientConfig;
use Cognesy\Polyglot\Http\Enums\HttpClientType;
use YourNamespace\Http\Drivers\CustomHttpDriver;

// Create a configuration for your custom driver
$config = new HttpClientConfig(
    httpClientType: HttpClientType::Custom->value,
    connectTimeout: 3,
    requestTimeout: 30,
    idleTimeout: -1,
    maxConcurrent: 5,
    poolTimeout: 60,
    failOnError: true
);

// Create your custom driver
$customDriver = new CustomHttpDriver($config);

// Create a client with your driver
$client = new HttpClient();
$client->withDriver($customDriver);

// Use the client as usual
$request = new HttpClientRequest(/* ... */);
$response = $client->handle($request);

Real-World Example: Creating a cURL Driver

Here’s a practical example of implementing a custom driver using PHP’s cURL extension directly:

<?php

namespace YourNamespace\Http\Drivers;

use Cognesy\Polyglot\Http\Contracts\CanHandleHttpRequest;
use Cognesy\Polyglot\Http\Contracts\HttpClientResponse;
use Cognesy\Polyglot\Http\Data\HttpClientConfig;
use Cognesy\Polyglot\Http\Data\HttpClientRequest;
use Cognesy\Polyglot\Http\Events\HttpRequestFailed;
use Cognesy\Polyglot\Http\Events\HttpRequestSent;
use Cognesy\Polyglot\Http\Events\HttpResponseReceived;
use Cognesy\Polyglot\Http\Exceptions\RequestException;
use Cognesy\Utils\Events\EventDispatcher;
use YourNamespace\Http\Adapters\CurlHttpResponse;

class CurlHttpDriver implements CanHandleHttpRequest
{
    /**
     * Constructor
     */
    public function __construct(
        protected HttpClientConfig $config,
        protected ?EventDispatcher $events = null,
    ) {
        $this->events = $events ?? new EventDispatcher();
    }

    /**
     * Handle an HTTP request
     */
    public function handle(HttpClientRequest $request): HttpClientResponse
    {
        $url = $request->url();
        $headers = $request->headers();
        $body = $request->body()->toString();
        $method = $request->method();
        $streaming = $request->isStreamed();

        // Dispatch event before sending request
        $this->events->dispatch(new HttpRequestSent(
            $url,
            $method,
            $headers,
            $request->body()->toArray()
        ));

        try {
            // Initialize cURL
            $ch = curl_init();

            // Format headers for cURL
            $curlHeaders = [];
            foreach ($headers as $name => $value) {
                if (is_array($value)) {
                    foreach ($value as $v) {
                        $curlHeaders[] = "{$name}: {$v}";
                    }
                } else {
                    $curlHeaders[] = "{$name}: {$value}";
                }
            }

            // Set cURL options
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => $curlHeaders,
                CURLOPT_CONNECTTIMEOUT => $this->config->connectTimeout,
                CURLOPT_TIMEOUT => $this->config->requestTimeout,
                CURLOPT_HEADER => true, // Include headers in output
                CURLOPT_FOLLOWLOCATION => true,
                CURLOPT_MAXREDIRS => 5,
            ]);

            // Set method-specific options
            switch ($method) {
                case 'POST':
                    curl_setopt($ch, CURLOPT_POST, true);
                    curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
                    break;
                case 'PUT':
                    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
                    curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
                    break;
                case 'PATCH':
                    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
                    curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
                    break;
                case 'DELETE':
                    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
                    if (!empty($body)) {
                        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
                    }
                    break;
                case 'HEAD':
                    curl_setopt($ch, CURLOPT_NOBODY, true);
                    break;
                case 'OPTIONS':
                    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'OPTIONS');
                    break;
                case 'GET':
                default:
                    // GET is the default in cURL
                    break;
            }

            // Handle streaming if requested
            $responseBody = '';
            $responseHeaders = [];

            if ($streaming) {
                $tempHandle = null;
                $tempFile = tempnam(sys_get_temp_dir(), 'curl_stream_');
                $tempHandle = fopen($tempFile, 'w+');

                curl_setopt($ch, CURLOPT_FILE, $tempHandle);
                curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($tempHandle) {
                    return fwrite($tempHandle, $data);
                });

                curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$responseHeaders) {
                    $len = strlen($header);
                    $header = explode(':', $header, 2);
                    if (count($header) < 2) {
                        return $len;
                    }

                    $name = trim($header[0]);
                    $value = trim($header[1]);

                    $responseHeaders[$name][] = $value;
                    return $len;
                });

                $result = curl_exec($ch);
                $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

                if ($result === false) {
                    throw new \RuntimeException('cURL error: ' . curl_error($ch));
                }

                // Rewind the temp file so it can be read
                rewind($tempHandle);

                // Create streaming response
                $response = new CurlHttpResponse(
                    statusCode: $statusCode,
                    headers: $responseHeaders,
                    body: '',
                    stream: $tempHandle,
                    isStreaming: true,
                    tempFile: $tempFile
                );
            } else {
                // For non-streaming responses, get the full response
                $result = curl_exec($ch);

                if ($result === false) {
                    throw new \RuntimeException('cURL error: ' . curl_error($ch));
                }

                $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

                // Extract headers and body
                $headerText = substr($result, 0, $headerSize);
                $responseBody = substr($result, $headerSize);

                // Parse headers
                $headers = explode("\r\n", $headerText);
                foreach ($headers as $header) {
                    $parts = explode(':', $header, 2);
                    if (count($parts) === 2) {
                        $name = trim($parts[0]);
                        $value = trim($parts[1]);
                        $responseHeaders[$name][] = $value;
                    }
                }

                // Create regular response
                $response = new CurlHttpResponse(
                    statusCode: $statusCode,
                    headers: $responseHeaders,
                    body: $responseBody
                );
            }

            // Clean up cURL
            curl_close($ch);

            // Dispatch event for successful response
            $this->events->dispatch(new HttpResponseReceived($response->statusCode()));

            return $response;

        } catch (\Exception $e) {
            // Clean up if needed
            if (isset($ch) && is_resource($ch)) {
                curl_close($ch);
            }

            // Dispatch event for failed request
            $this->events->dispatch(new HttpRequestFailed(
                $url,
                $method,
                $headers,
                $request->body()->toArray(),
                $e->getMessage()
            ));

            // Wrap the exception
            throw new RequestException($e);
        }
    }
}

And here’s the corresponding response adapter:

<?php

namespace YourNamespace\Http\Adapters;

use Cognesy\Polyglot\Http\Contracts\HttpClientResponse;
use Generator;

class CurlHttpResponse implements HttpClientResponse
{
    private $stream;
    private $tempFile;
    private $isStreaming;

    /**
     * Constructor
     */
    public function __construct(
        private int $statusCode,
        private array $headers,
        private string $body,
        $stream = null,
        bool $isStreaming = false,
        string $tempFile = null
    ) {
        $this->stream = $stream;
        $this->isStreaming = $isStreaming;
        $this->tempFile = $tempFile;
    }

    /**
     * Destructor - clean up temp files
     */
    public function __destruct()
    {
        if ($this->stream && is_resource($this->stream)) {
            fclose($this->stream);
        }

        if ($this->tempFile && file_exists($this->tempFile)) {
            unlink($this->tempFile);
        }
    }

    /**
     * Get the response status code
     */
    public function statusCode(): int
    {
        return $this->statusCode;
    }

    /**
     * Get the response headers
     */
    public function headers(): array
    {
        return $this->headers;
    }

    /**
     * Get the response body
     */
    public function body(): string
    {
        if ($this->isStreaming && $this->stream) {
            // For streaming responses, read the entire file
            rewind($this->stream);
            $contents = stream_get_contents($this->stream);
            rewind($this->stream);
            return $contents;
        }

        return $this->body;
    }

    /**
     * Stream the response body
     */
    public function stream(int $chunkSize = 1): Generator
    {
        if ($this->isStreaming && $this->stream) {
            // For streaming responses, yield chunks from the file
            rewind($this->stream);

            while (!feof($this->stream)) {
                yield fread($this->stream, $chunkSize);
            }
        } else {
            // For non-streaming responses, yield the entire body
            yield $this->body;
        }
    }
}