- Overview
- Installation
- How to use
- Server
- Client
- Building Http with a PSR-18 client and PSR-17 factories
- Making a request
- Reading the response
- Query parameters
- Custom headers and content type
- Default headers
- Setting the User-Agent
- Error handling
- Retrying failed requests
- Backoff policies
- Setting outbound headers
- Configuring timeouts
- Bounding the response body
- Testing with InMemoryTransport
- Extending with custom transports
- FAQ
- License
- Contributing
The library covers both sides of an HTTP exchange:
- Server side (
TinyBlocks\Http\Server) - decodes a PSR-7ServerRequestInterfaceinto typed accessors and builds outgoingResponseInterfaceinstances with cookies, cache-control, and status codes. - Client side (
TinyBlocks\Http\Client) - composes outbound requests, sends them through aTransportport backed by any PSR-18 client, and exposes responses with typed body and header access. - Client resilience (
TinyBlocks\Http\Client\Resilience) - decorates any PSR-18 client with retries, backoff policies, and notification of failed attempts, measuring each attempt with tiny-blocks/time.
Shared primitives at TinyBlocks\Http\: Method, Code, Headers, Headerable, ContentType, MimeType,
Charset, Cookie, SameSite, CacheControl, ResponseCacheDirectives, Link, LinkRelation, UserAgent.
composer require tiny-blocks/httpWrap a PSR-7 ServerRequestInterface and read typed fields from the body, route parameters, and query string.
<?phpdeclare(strict_types=1);
usePsr\Http\Message\ServerRequestInterface;
useTinyBlocks\Http\Server\Request;
/** @var ServerRequestInterface $psrRequest */$decoded = Request::from(request: $psrRequest)->decode();
$id = $decoded->uri()->route()->get(key: 'id')->toInteger();
$sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString();
$name = $decoded->body()->get(key: 'name')->toString();
$amount = $decoded->body()->get(key: 'amount')->toFloat();To pull several route parameters in one call, only(...) returns a map of typed Attribute
instances keyed by name. A key with no matching route parameter resolves to an Attribute
wrapping null rather than being omitted:
$attributes = $decoded->uri()->route()->only(keys: ['id', 'slug']);
$id = $attributes['id']->toInteger();
$slug = $attributes['slug']->toString();The HTTP method is available as a typed Method enum:
<?phpdeclare(strict_types=1);
usePsr\Http\Message\ServerRequestInterface;
useTinyBlocks\Http\Server\Request;
/** @var ServerRequestInterface $psrRequest */$method = Request::from(request: $psrRequest)->method();Server\Request also exposes the headers, the query parameters, and the raw body directly, without decoding the
payload. rawBody() returns the exact bytes received (handy for verifying a signature) and rewinds seekable streams, so
a later decode() still observes the full body.
<?phpdeclare(strict_types=1);
usePsr\Http\Message\ServerRequestInterface;
useTinyBlocks\Http\Server\Request;
/** @var ServerRequestInterface $psrRequest */$request = Request::from(request: $psrRequest);
$contentType = $request->headers()->get(name: 'content-type'); # case-insensitive lookup$trace = $request->header(name: 'X-Trace-Id'); # typed Attribute, or null$sort = $request->query()->get(key: 'sort')->toString();
$rawBody = $request->rawBody(); # exact bytes, undecodedEach helper returns a PSR-7 ResponseInterface and defaults to application/json:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Server\Response;
Response::ok(body: ['message' => 'Resource created successfully.']);
Response::created(body: ['id' => 42]);
Response::noContent();
Response::notFound(body: ['error' => 'Resource not found.']);For custom status codes, use from(...):
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Code;
useTinyBlocks\Http\Server\Response;
Response::from(body: ['status' => 'accepted'], code: Code::ACCEPTED);Attach additional headers via varargs of Headerable. They add to the application/json default rather than replacing
it, so a response carrying a Link or a Cache-Control header still declares its media type. Passing a ContentType
is what changes the media type, and it replaces the default instead of appending a second one:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\CacheControl;
useTinyBlocks\Http\ContentType;
useTinyBlocks\Http\ResponseCacheDirectives;
useTinyBlocks\Http\Server\Response;
$cacheControl = CacheControl::fromResponseDirectives(
ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000)
);
Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson())
->withHeader(name: 'X-Trace-Id', value: 'abc-123');withStatus($code, $reasonPhrase) honors the supplied reason phrase: when a non-empty string is passed,
getReasonPhrase() returns it instead of the enum-derived phrase.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Code;
useTinyBlocks\Http\Server\Response;
$response = Response::ok(body: null)->withStatus(Code::OK->value, 'All Good');
$response->getReasonPhrase(); # "All Good"Link implements Headerable and renders an RFC 8288 Link response header. Chain to(...) and and(...) with
LinkRelation targets to emit the standard pagination relations (first, prev, next, last, self), then attach
it to any response.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Link;
useTinyBlocks\Http\LinkRelation;
useTinyBlocks\Http\Server\Response;
$links = Link::to(uri: 'https://api.example.com/items?page=1', relation: LinkRelation::FIRST)
->and(uri: 'https://api.example.com/items?page=4', relation: LinkRelation::PREVIOUS)
->and(uri: 'https://api.example.com/items?page=6', relation: LinkRelation::NEXT)
->and(uri: 'https://api.example.com/items?page=9', relation: LinkRelation::LAST);
Response::ok(['data' => []], $links);The four targets fold into a single comma-separated Link response header in the order they were added.
Cookie implements Headerable and composes naturally with Response.
withSameSite(SameSite::NONE) automatically enables the Secure flag. Browsers reject
SameSite=None cookies that lack it. Calling secure() separately is not required.
withMaxAge(...) and withExpires(...) are mutually exclusive (last-write-wins): setting one clears the other. This
follows RFC 6265 §4.1.2.2, which specifies that Max-Age takes precedence over Expires when both are present.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Cookie;
useTinyBlocks\Http\SameSite;
useTinyBlocks\Http\Server\Response;
$session = Cookie::create(name: 'session', value: $token)
->secure()
->httpOnly()
->withPath(path: '/v1/sessions')
->withMaxAge(seconds: 604800)
->withSameSite(sameSite: SameSite::STRICT);
Response::ok(['ok' => true], $session);Setting SameSite=None without calling secure() first is safe. Secure is set automatically:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Cookie;
useTinyBlocks\Http\SameSite;
useTinyBlocks\Http\Server\Response;
# Secure is applied automatically when SameSite=None is set.$crossSite = Cookie::create(name: 'session', value: $token)
->withSameSite(sameSite: SameSite::NONE);
Response::ok(['ok' => true], $crossSite);To expire a cookie, use Cookie::expire(...) with the same Path and Domain used at creation. The expired cookie
carries both Max-Age=0 and Expires set to the Unix epoch: modern browsers honor Max-Age. The Expires fallback
covers legacy user agents.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Cookie;
useTinyBlocks\Http\SameSite;
useTinyBlocks\Http\Server\Response;
$expired = Cookie::expire(name: 'session')
->secure()
->httpOnly()
->withPath(path: '/v1/sessions')
->withSameSite(sameSite: SameSite::STRICT);
Response::noContent($expired);The Code enum carries the full RFC HTTP status set with typed helpers:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Code;
Code::OK->value; # 200
Code::OK->message(); # "OK"
Code::OK->isSuccess(); # true
Code::CONTINUE->isInformational(); # true
Code::MOVED_PERMANENTLY->isRedirection(); # true
Code::BAD_REQUEST->isClientError(); # true
Code::INTERNAL_SERVER_ERROR->isError(); # true
Code::INTERNAL_SERVER_ERROR->isServerError(); # true
Code::GATEWAY_TIMEOUT->isTimeout(); # true
Code::isValidCode(code: 200); # true
Code::isErrorCode(code: 500); # true
Code::isSuccessCode(code: 200); # true
Code::tryFromNullable(code: 200); # Code::OK
Code::tryFromNullable(code: 250); # null (status code not represented)
Code::tryFromNullable(code: null); # nullAssemble the facade with any PSR-18 client and PSR-17 factories.
<?phpdeclare(strict_types=1);
useGuzzleHttp\Client;
useGuzzleHttp\Psr7\HttpFactory;
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Http;
$factory = newHttpFactory();
$client = newClient(config: ['timeout' => 30, 'connect_timeout' => 5]);
$http = Http::create()
->withBaseUrl(url: 'https://api.example.com')
->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory))
->build();For a single-call construction without the fluent builder:
<?phpdeclare(strict_types=1);
useGuzzleHttp\Client;
useGuzzleHttp\Psr7\HttpFactory;
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Http;
$client = newClient(config: ['timeout' => 30, 'connect_timeout' => 5]);
$factory = newHttpFactory();
$http = Http::with(
baseUrl: 'https://api.example.com',
transport: NetworkTransport::with(
client: $client,
factory: $factory
)
);Six shortcut factories cover the most common HTTP methods. Supply only the arguments the request needs. The body,
queryParameters, and headers all default to absent or empty.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\ContentType;
useTinyBlocks\Http\Headers;
$response = $http->send(request: Request::get(url: '/v1/charges/abc123'));
$response = $http->send(
request: Request::post(
url: '/v1/charges',
body: ['amount' => 1000, 'currency' => 'usd'],
headers: Headers::from(ContentType::applicationJson())
)
);
$response = $http->send(request: Request::delete(url: '/v1/charges/abc123'));For HTTP methods not covered by the six shortcuts (OPTIONS, TRACE, CONNECT, or any custom method), use
Request::for(...), which accepts an explicit Method argument:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\Method;
$response = $http->send(
request: Request::for(url: '/v1/charges', method: Method::OPTIONS)
);Method also exposes RFC 9110 safety and idempotency predicates:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Method;
Method::GET->isSafe(); # true (RFC 9110 §9.2.1)
Method::POST->isSafe(); # false
Method::PUT->isIdempotent(); # true (RFC 9110 §9.2.2)
Method::POST->isIdempotent(); # false<?phpdeclare(strict_types=1);
if ($response->isSuccess()) {
$id = $response->body()->get(key: 'id')->toString();
$amount = $response->body()->get(key: 'amount')->toInteger();
}
$response->raw(); # Psr\Http\Message\ResponseInterface$response->code(); # Code enum$response->headers(); # TinyBlocks\Http\Headers value objectHeaders exposes case-insensitive lookup:
$contentType = $response->headers()->get(name: 'content-type'); # "application/json"$hasTrace = $response->headers()->has(name: 'X-Trace-Id'); # true$trace = $response->headers()->attribute(name: 'X-Trace-Id'); # Attribute, or nullorFail() returns the response unchanged on a 2xx status and throws HttpResponseUnsuccessful
otherwise. The exception carries the Code and the decoded Body, so a non-2xx status can be branched on and its
payload inspected in one place, then mapped to a domain error:
$body = $response->orFail()->body(); # throws HttpResponseUnsuccessful when the status is not 2xxPass query parameters via queryParameters:. The library encodes them in RFC 3986 form.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
$response = $http->send(
request: Request::get(
url: '/v1/charges',
queryParameters: ['status' => 'succeeded', 'limit' => 50]
)
);To replace query parameters on an existing request, use withQueryParameters(...):
$updated = $request->withQueryParameters(queryParameters: ['limit' => 100]);Compose any combination of Headerable via Headers::from(...):
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\ContentType;
useTinyBlocks\Http\Headerable;
useTinyBlocks\Http\Headers;
finalreadonlyclass IdempotencyKey implements Headerable
{
publicfunction__construct(privatestring$value)
{
}
publicfunctiontoArray(): array
{
return ['Idempotency-Key' => $this->value];
}
}
$response = $http->send(
request: Request::post(
url: '/v1/charges',
body: ['amount' => 1000],
headers: Headers::from(
ContentType::applicationJson(),
newIdempotencyKey(value: $key)
)
)
);Custom headers always win over the library's JSON defaults.
To add or replace a single header on an existing request, use withHeader(...). The lookup is case-insensitive:
replacing Content-Type via content-type still finds and replaces the entry.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
$updated = Request::get(url: '/v1/charges')
->withHeader(name: 'X-Trace-Id', value: 'abc-123');ContentType renders its raw header value via toString(), useful when composing the header by hand:
ContentType::applicationJson(charset: Charset::UTF_8)->toString(); # "application/json; charset=utf-8"
ContentType::applicationJson(charset: Charset::UTF_8)->mimeType(); # MimeType::APPLICATION_JSONCarry headers applied to every request (for example a static authorization header) by passing
defaultHeaders to the builder or to Http::with(...). Precedence per request is: a header set on the request wins
over a default, and a default wins over the JSON defaults (Accept/Content-Type: application/json).
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Headers;
useTinyBlocks\Http\Http;
$defaultHeaders = Headers::fromArray(entries: ['Authorization' => 'Bearer token']);
$http = Http::create()
->withBaseUrl(url: 'https://api.example.com')
->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory))
->withDefaultHeaders(headers: $defaultHeaders)
->build();The same headers can be supplied through the single-call factory:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Headers;
useTinyBlocks\Http\Http;
$http = Http::with(
baseUrl: 'https://api.example.com',
transport: NetworkTransport::with(client: $client, factory: $factory),
defaultHeaders: Headers::fromArray(entries: ['Authorization' => 'Bearer token'])
);The UserAgent value object implements Headerable and renders the standard
User-Agent header. An absent or empty version is normalized to "no version". The rendered header carries only the
product token in that case.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\Headers;
useTinyBlocks\Http\UserAgent;
$userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3');
$response = $http->send(
request: Request::get(
url: '/v1/charges',
headers: Headers::from($userAgent)
)
);When the version is unknown:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\UserAgent;
$userAgent = UserAgent::from(product: 'MyApp');
# renders as: User-Agent: MyAppUserAgent composes naturally with any other Headerable:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\ContentType;
useTinyBlocks\Http\Headers;
useTinyBlocks\Http\UserAgent;
$response = $http->send(
request: Request::post(
url: '/v1/charges',
body: ['amount' => 1000],
headers: Headers::from(
UserAgent::from(product: 'MyApp', version: '1.2.3'),
ContentType::applicationJson()
)
)
);Every failure raises an HttpException. TransportFailure (which extends HttpException) carries url(),
method(), and reason(), and is implemented by every exception raised by the transport layer. The remaining
HttpException implementations carry only the marker contract. Inspect their concrete class for the invariant they
violated. Catch the specific class when you need to react to a particular failure mode. Order of catch branches matters
because PHP matches the first applicable branch.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Exceptions\HttpException;
useTinyBlocks\Http\Exceptions\HttpRequestInvalid;
useTinyBlocks\Http\Exceptions\TransportFailure;
try {
$http->send(request: $request);
} catch (HttpRequestInvalid$exception) {
# PSR-18 RequestExceptionInterface: request malformed before transport.echo$exception->url();
echo$exception->method()->name;
echo$exception->reason();
} catch (TransportFailure$exception) {
# Other transport failures (network errors, generic PSR-18 client failures).echo$exception->url();
echo$exception->method()->name;
echo$exception->reason();
} catch (HttpException$exception) {
# Library-level failures (configuration, malformed path, exhausted in-memory transport).echo$exception::class;
}| Exception | Cause |
|---|---|
HttpRequestFailed | Generic PSR-18 ClientExceptionInterface. |
HttpNetworkFailed | PSR-18 NetworkExceptionInterface - DNS, timeout, connection refused. |
HttpRequestInvalid | PSR-18 RequestExceptionInterface - request malformed before transport. |
MalformedPath | Path attempts to escape the base URL (scheme, protocol-relative, control characters). |
NoMoreResponses | InMemoryTransport exhausted (programmer error). |
HttpConfigurationInvalid | Builder called without required dependencies. |
ClientNotConfigured | RetryingClientBuilder::build() called without a PSR-18 client. |
SynthesizedResponseHasNoRaw | Response::raw() called on a response created via Response::with(...). |
HttpResponseUnsuccessful | Response::orFail() called on a non-2xx response. |
RetryingClient is a PSR-18 decorator that retries transient failures. A network failure or a server error (HTTP 5xx)
is retried until the attempt ceiling is reached, sleeping the configured backoff delay between
attempts. A client error (HTTP 4xx) is never retried: the response is returned as is. Any other failure raised by the
decorated client propagates immediately. When the attempts are exhausted, the last response is returned or the last
exception is rethrown. The maxAttempts ceiling counts the first attempt, so maxAttempts: 2 means one retry.
Every failed attempt, the final one included, is reported to an optional RetryListener with the elapsed interval of
the attempt (an Elapsed from tiny-blocks/time), its AttemptOutcome
classification, the request, and the attempt number. Successful attempts are never reported.
AttemptOutcome | Trigger | Retried |
|---|---|---|
AttemptOutcome::TIMEOUT | Network failure whose message mentions a timeout, or an HTTP 408 or 504 response. | Yes |
AttemptOutcome::CONNECTION_RESET | Any other network failure. | Yes |
AttemptOutcome::SERVER_ERROR | Any other HTTP 5xx response, non-RFC codes included. | Yes |
AttemptOutcome::CLIENT_ERROR | Any other HTTP 4xx response. | No |
Assemble the decorator with the fluent builder returned by RetryingClient::create(). Only the PSR-18 client is
required, and build() raises ClientNotConfigured without one. Every other collaborator falls back to an opinionated
default: an ExponentialBackoff with random jitter, an attempt ceiling of three, the system monotonic clock and
sleeper, and a listener that ignores failures.
<?phpdeclare(strict_types=1);
useGuzzleHttp\Client;
usePsr\Http\Message\RequestInterface;
usePsr\Log\LoggerInterface;
useTinyBlocks\Http\Client\Resilience\AttemptOutcome;
useTinyBlocks\Http\Client\Resilience\FixedDelay;
useTinyBlocks\Http\Client\Resilience\RetryListener;
useTinyBlocks\Http\Client\Resilience\RetryingClient;
useTinyBlocks\Time\Elapsed;
finalreadonlyclass LoggingRetryListener implements RetryListener
{
publicfunction__construct(privateLoggerInterface$logger)
{
}
publicfunctionattemptFailed(
Elapsed$elapsed,
AttemptOutcome$outcome,
RequestInterface$request,
int$attemptNumber
): void {
$this->logger->warning('http_attempt_failed', [
'target' => (string)$request->getUri(),
'outcome' => $outcome->value,
'elapsed_ms' => $elapsed->toMilliseconds(),
'attempt_number' => $attemptNumber
]);
}
}
# One retry after a fixed 500 ms delay.$client = RetryingClient::create()
->withClient(client: newClient(config: ['timeout' => 10, 'connect_timeout' => 5]))
->withBackoff(backoff: FixedDelay::ofMicroseconds(microseconds: 500000))
->withListener(listener: newLoggingRetryListener(logger: $logger))
->withMaxAttempts(maxAttempts: 2)
->build();
$response = $client->sendRequest($request);The listener is optional. When omitted, failed attempts are silently ignored. Because RetryingClient is itself a
PSR-18 client, it plugs into anything that accepts one, including the library's own transport:
<?phpdeclare(strict_types=1);
useGuzzleHttp\Psr7\HttpFactory;
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Http;
$http = Http::with(
baseUrl: 'https://api.example.com',
transport: NetworkTransport::with(client: $client, factory: newHttpFactory())
);Backoff computes the delay, in microseconds, slept before the next attempt. Two implementations ship with the library.
Implement the interface for any other curve.
FixedDelay waits the same delay before every retry:
FixedDelay::ofMicroseconds(microseconds: 500000); # always 500 msExponentialBackoff doubles a base delay of 100 ms on every attempt and spreads it with a uniformly random jitter of up
to 30 percent of that value in either direction, keeping concurrent clients from retrying in lockstep against a
recovering dependency:
<?phpdeclare(strict_types=1);
useRandom\Randomizer;
useTinyBlocks\Http\Client\Resilience\ExponentialBackoff;
$backoff = ExponentialBackoff::with(randomizer: newRandomizer());
$backoff->delayFor(attempt: 1); # 100 ms, give or take up to 30 percent$backoff->delayFor(attempt: 2); # 200 ms, give or take up to 30 percent$backoff->delayFor(attempt: 3); # 400 ms, give or take up to 30 percentHeaderSettingClient is a PSR-18 decorator that sets headers on every outbound request, resolving each value at send
time. Values that change between requests (a correlation identifier, a rotating token) are always current. A resolved
value replaces any header of the same name already on the request, and a value resolving to an empty string leaves the
request untouched for that name.
<?phpdeclare(strict_types=1);
useGuzzleHttp\Client;
useTinyBlocks\Http\Client\HeaderSettingClient;
$client = HeaderSettingClient::with(client: newClient(), headerValues: [
'Correlation-Id' => staticfn(): string => $correlationId->toString()
]);It composes with the other client decorators. Wrapping it with RetryingClient re-resolves the headers on every
attempt.
PSR-18 does not standardize timeouts. Configure them on the underlying client before injection.
Guzzle:
<?phpdeclare(strict_types=1);
useGuzzleHttp\Client;
$client = newClient(config: ['timeout' => 30, 'connect_timeout' => 5]);Symfony HttpClient:
<?phpdeclare(strict_types=1);
useSymfony\Component\HttpClient\HttpClient;
useSymfony\Component\HttpClient\Psr18Client;
$client = newPsr18Client(client: HttpClient::create(defaultOptions: ['timeout' => 30]));Every response body is materialized into a PHP string before it is decoded. A ceiling bounds how much is read, so an
oversized payload from a hostile or misbehaving upstream cannot exhaust the process memory. The ceiling defaults to 16
MiB and crossing it raises ResponseBodyTooLarge
without decoding the payload.
Pass maxBytes to raise or lower it:
<?phpdeclare(strict_types=1);
useGuzzleHttp\Client;
useGuzzleHttp\Psr7\HttpFactory;
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Http;
$client = newClient(config: ['timeout' => 30, 'connect_timeout' => 5]);
$factory = newHttpFactory();
$http = Http::create()
->withBaseUrl(url: 'https://api.example.com')
->withTransport(
transport: NetworkTransport::with(
client: $client,
factory: $factory,
maxBytes: 1024 * 1024
)
)
->build();The ceiling is configured on the transport because that is where the body is read. A custom
Transport owns the decision for the responses it produces.
Pre-program responses with Response::with(...) and feed them to InMemoryTransport:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Response;
useTinyBlocks\Http\Client\Transports\InMemoryTransport;
useTinyBlocks\Http\Code;
useTinyBlocks\Http\Http;
$transport = InMemoryTransport::with(
responses: [
Response::with(code: Code::CREATED, body: ['id' => 'ch_abc123']),
Response::with(code: Code::OK, body: ['status' => 'paid'])
]
);
$http = Http::create()
->withBaseUrl(url: 'https://api.example.com')
->withTransport(transport: $transport)
->build();Calls consume responses in FIFO order. Exhaustion raises NoMoreResponses.
The transport records every request it receives, so a test can assert on the outbound request a consumer built without a hand-written transport double:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\Client\Response;
useTinyBlocks\Http\Client\Transports\InMemoryTransport;
useTinyBlocks\Http\Code;
$transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]);
$transport->send(request: Request::post(url: 'https://api.example.com/charges', body: ['amount' => 1000]));
# The most recently received request, or null when none was received.$lastReceived = $transport->lastReceivedRequest();
# Every received request, in the order they were sent.$received = $transport->receivedRequests();Implement Transport to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps
any inner Transport. For retries at the PSR-18 client level, the library ships RetryingClient. See
Retrying failed requests.
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Request;
useTinyBlocks\Http\Client\Response;
useTinyBlocks\Http\Client\Transport;
useTinyBlocks\Http\Exceptions\HttpNetworkFailed;
finalreadonlyclass RetryingTransport implements Transport
{
publicfunction__construct(
privateTransport$inner,
privateint$maxAttempts
) {
}
publicfunctionsend(Request$request): Response
{
$attempt = 0;
while (true) {
try {
return$this->inner->send(request: $request);
} catch (HttpNetworkFailed$exception) {
$attempt++;
if ($attempt >= $this->maxAttempts) {
throw$exception;
}
}
}
}
}Compose it into the facade:
<?phpdeclare(strict_types=1);
useTinyBlocks\Http\Client\Transports\NetworkTransport;
useTinyBlocks\Http\Http;
$http = Http::create()
->withBaseUrl(url: 'https://api.example.com')
->withTransport(
transport: newRetryingTransport(
inner: NetworkTransport::with(client: $client, factory: $factory),
maxAttempts: 3
)
)
->build();Headerable is the contract implemented by classes that emit one or more header lines such as ContentType, Cookie,
CacheControl, and any custom header type. Headers is the value object that carries the consolidated header set of an
HTTP request or response, with case-insensitive lookup and merging.
PSR-18 does not standardize timeouts. Exposing them in the facade would require a transport-specific contract that leaks the underlying client. Configure timeouts on the PSR-18 client before injecting it.
A response created via Response::with(...) has no PSR-7 backing - it exists only for in-process scenarios (tests,
InMemoryTransport). Calling raw() in that mode is a programmer error and raises SynthesizedResponseHasNoRaw.
To protect the configured base URL from being hijacked by paths that contain a scheme, are protocol-relative, or carry
control characters. Such inputs raise MalformedPath before the transport is invoked.
Response::from() requires a code present in the enum, which covers every RFC code in use. Non-RFC status codes are
reachable through Response::raw()->getStatusCode().
Http is licensed under MIT.
Please follow the contributing guidelines to contribute to the project.