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 kompatibel —
ClientInterfacevollstä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-Methoden —
get(),post(),put(),patch(),delete(),head()mit automatischem JSON-Encoding
Installation
composer require jardisadapter/httpGitHub: jardisAdapter/http
Erforderliche PHP-Extension: ext-curl
Grundlegende Nutzung
Einfache Requests
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
$request = $factory->createRequest('GET', 'https://api.example.com/users')
->withHeader('Accept', 'application/json');
$response = $client->sendRequest($request);
$response->getStatusCode(); // 200
$response->getBody()->getContents(); // Response BodyKonfiguration
Alle Optionen werden über ClientConfig gesteuert. Nur konfigurierte Features werden instanziiert:
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:
$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/dataAuthentifizierung
// 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 verwendetDefault-Headers
$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:
$config = new ClientConfig(
maxRetries: 3, // 3 Wiederholungen nach dem ersten Versuch
retryDelayMs: 200, // 200ms Basis-Delay
);Exponential Backoff
| Versuch | Delay |
|---|---|
| 1 (Initial) | sofort |
| 2 | 200ms |
| 3 | 400ms |
| 4 | 800ms |
Was löst Retry aus?
| Situation | Retry? |
|---|---|
| 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:
$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
$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:
| Exception | Ursache |
|---|---|
NetworkException | DNS-Fehler, Connection Refused, Timeout, SSL-Fehler |
RequestException | Ungültiger Request, cURL-Initialisierungsfehler |
HttpClientException | Basis für beide (implements PSR-18 ClientExceptionInterface) |
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ührungVerzeichnisstruktur
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 UriAPI-Referenz
HttpClient
| Methode | Signatur | Beschreibung |
|---|---|---|
sendRequest | sendRequest(RequestInterface $request): ResponseInterface | PSR-18 Standard |
get | get(string $uri, array $headers = []): ResponseInterface | GET-Request |
post | post(string $uri, array $data = [], array $headers = []): ResponseInterface | POST mit JSON |
put | put(string $uri, array $data = [], array $headers = []): ResponseInterface | PUT mit JSON |
patch | patch(string $uri, array $data = [], array $headers = []): ResponseInterface | PATCH mit JSON |
delete | delete(string $uri, array $headers = []): ResponseInterface | DELETE |
head | head(string $uri, array $headers = []): ResponseInterface | HEAD |
ClientConfig
| Property | Typ | Standard | Beschreibung |
|---|---|---|---|
timeout | int | 30 | Request-Timeout (Sekunden) |
connectTimeout | int | 10 | Verbindungs-Timeout |
baseUrl | ?string | null | Base-URL |
verifySsl | bool | true | SSL-Verifizierung |
defaultHeaders | array | [] | Standard-Headers |
bearerToken | ?string | null | Bearer-Token |
basicUser | ?string | null | Basic-Auth User |
basicPassword | ?string | null | Basic-Auth Passwort |
maxRetries | int | 0 | Retry-Versuche |
retryDelayMs | int | 100 | Basis-Delay (ms) |
Vollständiges Beispiel
API-Client mit Base-URL, Bearer-Auth, Default-Headers und Retry:
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();
}