jooservices/client

Strict, extensible PHP 8.5+ HTTP client wrapper for JOOservices

Maintainers

Package info

github.com/jooservices/client

pkg:composer/jooservices/client

Transparency log

Statistics

Installs: 2 825

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 0

v3.0.0 2026-08-19 20:05 UTC

README

A robust, layered HTTP client wrapper designed for extensibility, strict typing, and high performance. Built with a clean, package-oriented architecture that decouples transport integration from client behavior.

codecov CI Codacy Badge OpenSSF Scorecard PHP Version License: MIT Packagist Version

The JOOservices Client is a PHP 8.5+ HTTP client package.

Package name: jooservices/client

Latest stable release: v3.0.0 (see CHANGELOG)

Features

  • Strictly Typed: Configuration object (ClientConfig) ensures type safety before requests start (validated via PHP 8.5 property hooks).
  • Layered Architecture: Guzzle (default), native cURL, and cURL multi transports are isolated from core logic.
  • Resilience: Built-in Retry (Backoff/Jitter + onRetry hook), Circuit Breaker (per-host/partition_key scoping + open/close hooks), Rate Limit (server RateLimit-Reset/Retry-After hints), Bulkhead, Fallback, Deadline, HTTP error mapping, and curl↔Guzzle transport failover.
  • Async middleware: First-party middleware chains Guzzle promises without wait() (AsyncMiddlewareInterface).
  • Observability: Logging, W3C trace context, metrics (including connection reuse), correlation IDs, and onError recovery interceptors.
  • Auth: Bearer, API key, Basic auth, and OAuth token refresh middleware.
  • Performance: < 10μs overhead per request; true concurrency via cURL multi batch().
  • Guzzle 7.10 / 8: Native Guzzle transport with stable package exceptions.
  • Testing: Builder-native fakes with pattern-based stubs, ordered assertions, deterministic retry and rate-limit sleeps.

Installation

composer require jooservices/client

Runtime dependencies include Guzzle (^7.10 || ^8.0), jooservices/exceptions (^0.5 || ^1.0), and psr/log. Logging sinks (Monolog, Mongo, SQL, …) belong in the consuming application.

Native cURL transport

Use the optional native cURL transport when ext-curl is installed. Synchronous requests support package middleware and portable options. Prefer buildSync() so callers are not typed for async APIs the transport rejects.

$client = ClientBuilder::create()
    ->withTransport('curl') // or withCurlAdapter()
    ->buildSync();

For truly concurrent batching without Guzzle promises, enable the cURL multi transport:

$batchClient = ClientBuilder::create()
    ->withBaseUri('https://api.example.com')
    ->withTransport('curl_multi') // or withCurlMultiAdapter()
    ->buildCurlMulti();

$results = $batchClient->batch([
    'user1' => new \GuzzleHttp\Psr7\Request('GET', '/users/1'),
    'user2' => new \GuzzleHttp\Psr7\Request('GET', '/users/2'),
]); // array<string, ResponseWrapper|Throwable> — failures are mapped exceptions

echo $results['user1']->body();

CurlMultiBatchClient also implements HttpClientInterface, so single-request calls (with middleware) and concurrent batch() (transport-level) share one instance. Batch items may be RequestInterface, callable(): RequestInterface, or [RequestInterface, options] tuples.

Portable on cURL Notes
json, form_params, query, auth, multipart, body Body/StreamInterface supported
sink, max_size, resume, stream Downloads; max_size is transport-agnostic
timeout, connect_timeout, verify, cert, ssl_key TLS + timeouts
allow_redirects max, protocols, track_redirects, strict, referer, on_redirect
proxy, progress, cookies, version, force_ip_resolve, on_stats cookies = jar or name => value map; version includes HTTP/3 when libcurl supports it

Non-portable keys (handler, curl, delay, on_headers, read_timeout) throw. Enable strict mode with withCurlAdapter(strictPortableOptions: true) to also reject unknown keys. Async / batch() remain Guzzle-only unless you fail over with withFailoverTransport('guzzle').

$client = ClientBuilder::create()
    ->withTransport('curl')
    ->withFailoverTransport('guzzle')
    ->build();

Quick Start

Basic Usage

Use the ClientBuilder to create an instance.

use JOOservices\Client\Client\ClientBuilder;

$client = ClientBuilder::create()
    ->withBaseUri('https://api.example.com')
    ->withTimeout(5)
    ->withHeader('Authorization', 'Bearer token')
    ->build();

$response = $client->get('/users/1');

echo $response->status(); // 200
print_r($response->json()); // ['id' => 1, ...]

Downloading Files

The client supports memory-efficient file downloads. The response body is written directly to the target destination path using a stream (avoiding buffering the download in memory). Response body logging is automatically skipped for downloads.

// Synchronous download
$client->download('https://example.com/largefile.zip', '/path/to/save/largefile.zip');

// Asynchronous download
$promise = $client->downloadAsync('https://example.com/largefile.zip', '/path/to/save/largefile.zip');
$promise->wait();

JSON, Uploads, and Response Helpers

$client = ClientBuilder::create()->withJsonDefaults()->withRedirects(true)->build();
$response = $client->postJson('/users', ['name' => 'Ada']);

if ($response->successful()) {
    echo $response->body();
}

$client->upload('/documents', __DIR__ . '/report.pdf', ['category' => 'reports']);

Map JSON bodies to typed objects:

$dto = $response->as(UserDto::class);   // alias of toDto()
$obj = $response->object();             // stdClass
$users = $response->collect(UserDto::class); // JSON list -> list<UserDto>

upload() accepts a readable file path and creates the multipart file part. body() preserves the current position for seekable streams.

Testing with fakes

Production code and tests share the same ClientBuilder::create()->build() entry point — call ClientBuilder::fake() first and everything else about how the client is built stays the same.

use JOOservices\Client\Testing\TestResponse;

ClientBuilder::fake([
    TestResponse::times(2, TestResponse::status(503))->then(TestResponse::ok(['id' => 1])),
]);

$client = ClientBuilder::create()->withRetry(new RetryConfig())->build();
$response = $client->get('/health');

ClientBuilder::assertSentCount(3);          // 2 failed attempts + 1 success, all recorded
ClientBuilder::assertSent('GET', '/health');
ClientBuilder::clearFake();                 // always call in tearDown(), or use InteractsWithHttpClient

TestResponse covers every outcome the real transport can produce: ok(), json(), status(), notFound(), serverError(), timeout(), connectionError(), fatal(), and asHttpError() for forcing an HttpResponseException regardless of the builder's withHttpErrors() setting. Retry and rate-limit middleware automatically use a NullSleeper while faked, so retried requests don't actually sleep. Fakes cannot be combined with a custom adapter or Guzzle handler option — build() throws InvalidConfigurationException instead of silently hitting real network.

Route stubs by URI pattern — route queues deplete before the FIFO queue, and every routed request is recorded in the history:

ClientBuilder::fake();
ClientBuilder::respond('GET', 'https://api.example.com/users/*', TestResponse::ok(['id' => 1]));

$response = ClientBuilder::create()->build()->get('https://api.example.com/users/42');

ClientBuilder::assertSentTimes(1, 'GET', 'https://api.example.com/users/42');
ClientBuilder::assertSentInOrder(['GET', 'https://api.example.com/users/42'], 'GET');

Use the InteractsWithHttpClient trait to clear the fake automatically after every test:

use JOOservices\Client\Testing\InteractsWithHttpClient;

final class BillingClientTest extends TestCase
{
    use InteractsWithHttpClient; // calls ClientBuilder::clearFake() in tearDown()
}

Async calls retain the documented synchronous-middleware limitation.

Async Requests & Batching

// Single Async Request
$promise = $client->getAsync('/users/1');
$response = $promise->wait();

// Batch Processing (Concurrent)
$results = $client->batch([
    'user1' => fn() => $client->getAsync('/users/1'),
    'user2' => fn() => $client->getAsync('/users/2'),
]);

print_r($results['user1']->json());

Advanced Configuration

Resilience (Retry & Circuit Breaker)

use JOOservices\Client\Resilience\RetryConfig;
use JOOservices\Client\Resilience\CircuitBreakerConfig;

$client = ClientBuilder::create()
    ->withRetry(new RetryConfig(
        maxAttempts: 3,
        baseDelayMs: 100
    ))
    ->withCircuitBreaker(new CircuitBreakerConfig(
        failureThreshold: 5,
        recoveryTimeoutMs: 10000
    ))
    ->build();

Production middleware stack

Register middleware outermost-first. Use individual helpers or the preset:

use JOOservices\Client\Client\ClientBuilder;
use JOOservices\Client\Resilience\RateLimitConfig;
use JOOservices\Client\Resilience\RetryConfig;
use JOOservices\Client\Support\InMemoryMetricsRecorder;
use JOOservices\Client\ValueObjects\TraceContextConfig;

$client = ClientBuilder::create()
    ->withBaseUri('https://api.example.com')
    ->withHeader('Accept', 'application/json')
    ->withBearerToken($token)
    ->withProductionMiddlewareOrder(
        rateLimit: new RateLimitConfig(maxTokens: 50, refillRatePerSecond: 50),
        traceContext: new TraceContextConfig(),
        metrics: new InMemoryMetricsRecorder(),
        retry: new RetryConfig(),
    )
    ->build();

Per-request options: idempotency_key, deadline_ms, rate_limit_bypass, cache_bypass, cache_ttl, partition_key, fallback_enabled.

Resilience callbacks & error recovery

Observe resilience decisions or recover from failures without custom middleware:

use JOOservices\Client\Resilience\CircuitBreakerConfig;
use JOOservices\Client\Resilience\RetryConfig;

$client = ClientBuilder::create()
    ->withRetry(new RetryConfig(
        onRetry: fn (int $attempt, int $delayMs, $reason, $request) => logger()->warning('retrying', [
            'attempt' => $attempt, 'delay_ms' => $delayMs,
        ])
    ))
    ->withCircuitBreaker(new CircuitBreakerConfig(
        onCircuitOpen: fn (string $partitionKey) => alert('circuit open: ' . $partitionKey),
        onCircuitClose: fn (string $partitionKey) => alert('circuit closed: ' . $partitionKey),
    ))
    // Recover: return a fallback ResponseInterface instead of failing.
    ->onError(fn (Throwable|Psr\Http\Message\ResponseInterface $outcome, array $options) =>
        $outcome instanceof Throwable ? new \GuzzleHttp\Psr7\Response(503, [], 'fallback') : $outcome)
    ->build();

Scope the circuit breaker per tenant with CircuitBreakerConfig(respectPerRequestPartitionKey: true) and pass partition_key per request. The rate limiter automatically honours server RateLimit-Reset (epoch) and Retry-After (429/503) headers; disable with RateLimitConfig(honorServerHeaders: false).

Progress callbacks

$client = ClientBuilder::create()
    ->withProgress(
        download: fn (int $total, int $downloaded) => updateProgressBar($total, $downloaded),
        upload: fn (int $total, int $uploaded) => updateProgressBar($total, $uploaded),
    )
    ->build();

$client->download('https://example.com/big.zip', '/tmp/big.zip');

Header merge policy

withHeaders($headers, overwrite: true) (default) lets incoming values win over previously set headers; overwrite: false keeps previously set values (defaults-first). Per-request headers always win over builder-level headers.

Logging & Caching

The client decides when and what to log (LoggingMiddleware + LogSanitizer). Inject where via any PSR-3 logger:

use JOOservices\Client\Cache\FilesystemCache;
use JOOservices\Client\Support\CachedExternalWanIpProvider;
use Psr\Log\LoggerInterface;

/** @var LoggerInterface $logger */
$cache = new FilesystemCache(__DIR__ . '/cache');

$client = ClientBuilder::create()
    ->withLogger($logger, logBodies: false)
    ->withWanIpProvider(new CachedExternalWanIpProvider()) // opt-in WAN IP in logs
    ->withCache($cache, defaultTtl: 3600)
    ->build();

Request and response body logging should stay opt-in. Keep logBodies: false unless the integration explicitly needs body-level diagnostics and the payload is safe to record.

WAN/public IP enrichment is also opt-in. It may be personal or infrastructure-sensitive data; enable it only where collection, retention, and access to the resulting log context are appropriate for your deployment.

3.0 migration notes

  • Mongo/MySQL package loggers and withDefaultLogging() / MonologFactory are removed. Wire your own LoggerInterface — see UPGRADE-3.0.md.
  • Historical 2.x notes: UPGRADE-2.0.md, UPGRADE-2.3.md.
  • WAN IP and body logging remain opt-in.

Quality Assurance

The repository uses the DTO-style quality contract with a few client-specific additions.

composer check

Run composer lint:all and composer test directly when you want the underlying steps separately; use composer check for the standard combined gate.

Additional validation commands:

  • composer lint:fix
  • composer test:coverage
  • composer bench
  • composer ci

Intentional client-specific differences from the DTO baseline:

  • 98% coverage gate on composer test:coverage
  • dedicated benchmark job with PHPBench
  • optional live-network job for real external IP logging checks
  • secret scanning in CI is Security / Scan - secret (Gitleaks inside the shared Security job)

Repository-standard auxiliary automation now also matches DTO more closely:

  • semantic PR titles require an uppercase subject
  • pull requests are auto-labeled with DTO-style label categories
  • releases validate tags before publishing GitHub releases and can notify Packagist when credentials are configured

Coverage remains an intentional client-specific divergence: this repo keeps a 98% gate and a narrower excluded-source set so the enforced threshold stays meaningful for the exercised client runtime surface.

AI Development Workflow

This package includes AI-oriented scaffolding to keep delivery consistent with quality gates.

When AI changes code, run:

composer check

Docker Development

If PHP is not installed locally, run everything in Docker.

docker compose up -d --build php
docker compose run --rm php composer install
docker compose run --rm php composer test

For live network integration tests (real sites), run:

docker compose run --rm -e JOOCLIENT_RUN_LIVE_NETWORK_TESTS=1 php \
    vendor/bin/phpunit tests/Feature/Logging/RealSiteIpLoggingTest.php

This test hits:

  • https://httpbin.org/get
  • https://example.com
  • https://google.com

Contributing

See CONTRIBUTING.md for details.

Normal feature and fix work branches from develop and PRs back into develop. Release preparation uses release/<version> branches from develop into master.