Skip to content

HTTP Client

PSR-18 HTTP-Client mit Handler-Pipeline, Retry und eigener PSR-7-Implementierung: ohne Guzzle, ohne Symfony.

Einführung

HTTP-Clients in PHP bedeuten oft: Guzzle mit 30+ Abhängigkeiten, oder Symfony HttpClient mit Framework-Kopplung. Wer nur einen sauberen PSR-18-Client braucht, der cURL nutzt und sich konfigurieren lässt, steht vor der Wahl zwischen zu viel und zu wenig.

jardisadapter/http ist die schlanke Alternative. Zwei öffentliche Klassen (HttpClient und ClientConfig) mit einer eigenen PSR-7/PSR-17-Implementierung und einer konfigurierbaren Handler-Pipeline:

  • PSR-18 kompatibelClientInterface vollständig implementiert, inkl. korrekter Exception-Klassifikation
  • Eigene PSR-7 + PSR-17 — Request, Response, Stream, Uri und Factory ohne externe Abhängigkeiten
  • Handler-Pipeline — Base-URL, Default-Headers, Bearer/Basic-Auth werden nur instanziiert wenn konfiguriert
  • Retry mit Exponential Backoff — automatische Wiederholung bei 5xx und Netzwerkfehlern
  • Austauschbarer Transport — cURL-Transport per Default, eigener Transport per Closure für Tests
  • Convenience-Methodenget(), post(), put(), patch(), delete(), head() mit automatischem JSON-Encoding

Installation

bash
composer require jardisadapter/http

GitHub: jardisAdapter/http

Erforderliche PHP-Extension: ext-curl

Grundlegende Nutzung

Einfache Requests

php
use JardisAdapter\Http\HttpClient;
use JardisAdapter\Http\Config\ClientConfig;
use JardisAdapter\Http\Message\Psr17Factory;

$factory = new Psr17Factory();
$client = new HttpClient($factory, $factory, $factory, $factory);

// GET
$response = $client->get('https://api.example.com/users');
$body = json_decode($response->getBody()->getContents(), true);

// POST mit automatischem JSON-Encoding
$response = $client->post('https://api.example.com/users', [
    'name'  => 'John Doe',
    'email' => 'john@example.com',
]);

// PUT, PATCH, DELETE
$client->put('https://api.example.com/users/42', ['name' => 'Jane Doe']);
$client->patch('https://api.example.com/users/42', ['email' => 'jane@example.com']);
$client->delete('https://api.example.com/users/42');

PSR-18 Standard-API

php
$request = $factory->createRequest('GET', 'https://api.example.com/users')
    ->withHeader('Accept', 'application/json');

$response = $client->sendRequest($request);
$response->getStatusCode();       // 200
$response->getBody()->getContents(); // Response Body

Konfiguration

Alle Optionen werden über ClientConfig gesteuert. Nur konfigurierte Features werden instanziiert:

php
use JardisAdapter\Http\Config\ClientConfig;

$config = new ClientConfig(
    timeout: 30,                    // Request-Timeout in Sekunden
    connectTimeout: 10,             // Verbindungs-Timeout
    baseUrl: 'https://api.example.com/v1',  // Base-URL für relative Pfade
    verifySsl: true,                // SSL-Verifizierung
    defaultHeaders: [               // Standard-Headers für jeden Request
        'Accept' => 'application/json',
        'X-Api-Version' => '2',
    ],
    bearerToken: 'eyJhbG...',       // Bearer-Token (hat Vorrang vor Basic)
    maxRetries: 3,                  // Retry-Versuche bei 5xx/Netzwerkfehler
    retryDelayMs: 200,              // Basis-Delay für Exponential Backoff
);

$client = new HttpClient($factory, $factory, $factory, $factory, $config);

Base-URL

Relative Pfade werden automatisch aufgelöst:

php
$config = new ClientConfig(baseUrl: 'https://api.example.com/v1');
$client = new HttpClient($factory, $factory, $factory, $factory, $config);

$client->get('/users');        // → https://api.example.com/v1/users
$client->get('/users/42');     // → https://api.example.com/v1/users/42

// Absolute URLs werden nicht verändert
$client->get('https://other.api.com/data');  // → https://other.api.com/data

Authentifizierung

php
// Bearer Token (OAuth2, JWT, API-Keys)
$config = new ClientConfig(bearerToken: 'eyJhbG...');
// → Authorization: Bearer eyJhbG...

// Basic Auth
$config = new ClientConfig(basicUser: 'admin', basicPassword: 's3cret');
// → Authorization: Basic YWRtaW46czNjcmV0

// Bearer hat Vorrang — wenn beides gesetzt ist, wird nur Bearer verwendet

Default-Headers

php
$config = new ClientConfig(defaultHeaders: [
    'Accept' => 'application/json',
    'X-Tenant-Id' => 'acme',
]);

// Per-Request-Headers überschreiben Default-Headers
$client->get('/users', ['Accept' => 'text/xml']);
// → Accept: text/xml (per-Request gewinnt)

Retry

Automatische Wiederholung bei Server-Fehlern und Netzwerkproblemen:

php
$config = new ClientConfig(
    maxRetries: 3,       // 3 Wiederholungen nach dem ersten Versuch
    retryDelayMs: 200,   // 200ms Basis-Delay
);

Exponential Backoff

VersuchDelay
1 (Initial)sofort
2200ms
3400ms
4800ms

Was löst Retry aus?

SituationRetry?
HTTP 5xx (Server Error)Ja
Netzwerkfehler (DNS, Timeout, Connection Refused)Ja
HTTP 4xx (Client Error)Nein — sofort zurückgegeben
HTTP 2xx/3xx (Erfolg)Nein — sofort zurückgegeben

Verhalten bei Erschöpfung

  • Alle Retries auf 5xx verbraucht → letzte 5xx-Response wird zurückgegeben (kein Throw)
  • Alle Retries auf Exception verbraucht → letzte Exception wird geworfen

Eigener Transport (Tests)

Der cURL-Transport kann durch eine Closure ersetzt werden, ideal für Tests ohne HTTP-Server:

php
$client = new HttpClient(
    $factory, $factory, $factory, $factory,
    config: new ClientConfig(),
    transport: function ($request, $config) use ($factory) {
        return $factory->createResponse(200)
            ->withBody($factory->createStream('{"mocked": true}'));
    },
);

$response = $client->get('https://api.example.com/users');
// Status: 200, Body: {"mocked": true}

Request-Capturing

php
$captured = null;
$transport = function ($request) use (&$captured, $factory) {
    $captured = $request;
    return $factory->createResponse(200);
};

$client = new HttpClient(
    $factory, $factory, $factory, $factory,
    config: new ClientConfig(baseUrl: 'https://api.example.com'),
    transport: $transport,
);

$client->get('/users');
$captured->getUri()->__toString();  // 'https://api.example.com/users'
$captured->getMethod();             // 'GET'

Fehlerbehandlung

PSR-18-konform: HTTP-Fehlercodes (4xx, 5xx) sind keine Exceptions, sondern gültige Responses. Nur Transport-Fehler werfen Exceptions:

ExceptionUrsache
NetworkExceptionDNS-Fehler, Connection Refused, Timeout, SSL-Fehler
RequestExceptionUngültiger Request, cURL-Initialisierungsfehler
HttpClientExceptionBasis für beide (implements PSR-18 ClientExceptionInterface)
php
use JardisAdapter\Http\Exception\NetworkException;
use JardisAdapter\Http\Exception\RequestException;

try {
    $response = $client->get('https://api.example.com/users');

    if ($response->getStatusCode() >= 400) {
        // HTTP-Fehler — keine Exception, sondern Response
    }
} catch (NetworkException $e) {
    // Netzwerk-Problem
    $failedRequest = $e->getRequest();
} catch (RequestException $e) {
    // Ungültiger Request
}

Architektur

Das Package folgt dem Closure-Orchestrator-Pattern mit einer zweistufigen Pipeline:

HttpClient                             ← Orchestrator (PSR-18)
├── Transformers (Request → Request)
│   ├── BaseUrl                        ← Relative URLs auflösen
│   ├── DefaultHeaders                 ← Standard-Headers setzen
│   ├── BearerAuth                     ← Authorization: Bearer
│   └── BasicAuth                      ← Authorization: Basic
└── Transport (Request → Response)
    └── Retry (optional, bei maxRetries > 0)  ← wrappt CurlTransport
        └── CurlTransport                     ← cURL-Ausführung

Verzeichnisstruktur

src/
├── HttpClient.php              ← Orchestrator (PSR-18)
├── Config/
│   └── ClientConfig.php        ← Konfiguration
├── Handler/
│   ├── BaseUrl.php             ← URL-Auflösung
│   ├── DefaultHeaders.php      ← Standard-Headers
│   ├── BearerAuth.php          ← Bearer-Token
│   ├── BasicAuth.php           ← Basic-Auth
│   ├── CurlTransport.php       ← cURL-Transport
│   └── Retry.php               ← Retry-Wrapper
├── Exception/
│   ├── HttpClientException.php
│   ├── NetworkException.php
│   └── RequestException.php
└── Message/
    ├── Psr17Factory.php        ← PSR-17 Factory
    ├── Request.php             ← PSR-7 Request
    ├── Response.php            ← PSR-7 Response
    ├── Stream.php              ← PSR-7 Stream
    └── Uri.php                 ← PSR-7 Uri

API-Referenz

HttpClient

MethodeSignaturBeschreibung
sendRequestsendRequest(RequestInterface $request): ResponseInterfacePSR-18 Standard
getget(string $uri, array $headers = []): ResponseInterfaceGET-Request
postpost(string $uri, array $data = [], array $headers = []): ResponseInterfacePOST mit JSON
putput(string $uri, array $data = [], array $headers = []): ResponseInterfacePUT mit JSON
patchpatch(string $uri, array $data = [], array $headers = []): ResponseInterfacePATCH mit JSON
deletedelete(string $uri, array $headers = []): ResponseInterfaceDELETE
headhead(string $uri, array $headers = []): ResponseInterfaceHEAD

ClientConfig

PropertyTypStandardBeschreibung
timeoutint30Request-Timeout (Sekunden)
connectTimeoutint10Verbindungs-Timeout
baseUrl?stringnullBase-URL
verifySslbooltrueSSL-Verifizierung
defaultHeadersarray[]Standard-Headers
bearerToken?stringnullBearer-Token
basicUser?stringnullBasic-Auth User
basicPassword?stringnullBasic-Auth Passwort
maxRetriesint0Retry-Versuche
retryDelayMsint100Basis-Delay (ms)

Vollständiges Beispiel

API-Client mit Base-URL, Bearer-Auth, Default-Headers und Retry:

php
use JardisAdapter\Http\HttpClient;
use JardisAdapter\Http\Config\ClientConfig;
use JardisAdapter\Http\Message\Psr17Factory;
use JardisAdapter\Http\Exception\NetworkException;

$factory = new Psr17Factory();

$client = new HttpClient(
    $factory, $factory, $factory, $factory,
    new ClientConfig(
        baseUrl: 'https://api.erp-system.com/v2',
        bearerToken: $apiToken,
        defaultHeaders: [
            'Accept' => 'application/json',
            'X-Tenant-Id' => 'acme-corp',
        ],
        maxRetries: 2,
        retryDelayMs: 500,
        timeout: 15,
    ),
);

// Produkte laden
$response = $client->get('/products', ['X-Page-Size' => '50']);
$products = json_decode($response->getBody()->getContents(), true);

// Bestellung erstellen
$response = $client->post('/orders', [
    'customer_id' => 42,
    'items' => [
        ['product_id' => 'P-001', 'quantity' => 2],
        ['product_id' => 'P-002', 'quantity' => 1],
    ],
]);

if ($response->getStatusCode() === 201) {
    $order = json_decode($response->getBody()->getContents(), true);
    echo "Order created: " . $order['id'];
}

// Fehlerbehandlung
try {
    $response = $client->get('/health');
} catch (NetworkException $e) {
    // API nicht erreichbar — nach 2 Retries
    echo "API unreachable: " . $e->getMessage();
}