|
| 1 | +--- |
| 2 | +name: configure-http-client |
| 3 | +description: Use when setting up or changing the HTTP client and transport for a phpro/http-tools integration — base URI, authentication headers, logging with sensitive data stripped, retries, plugin order, choosing a transport preset or encoder/decoder, or making the same wiring reusable from tests. Triggers on "configure the client", "add a plugin", "set the base url", "how do I log requests", "which preset should I use", "add auth to the API client". |
| 4 | +--- |
| 5 | + |
| 6 | +# Configure an HTTP client and transport |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +Every integration gets three small classes, written once and reused by **all** its request handlers *and its tests*: |
| 11 | + |
| 12 | +| Class | Responsibility | |
| 13 | +|---|---| |
| 14 | +| `…ClientConfig` | The variable inputs: base URI, credentials, logger. A readonly DTO. | |
| 15 | +| `…ClientFactory` | Config → PSR-18 `ClientInterface`, via `ClientBuilder` plugins. | |
| 16 | +| `…TransportFactory` | Client → `TransportInterface`, via a preset + any decorators. | |
| 17 | + |
| 18 | +**Core principle: the same factory the application uses must be usable from a test.** If a test has to rebuild the plugin stack by hand, the two will drift and your tests will pass against a client that doesn't exist in production. So: take the seams as constructor arguments (logger, recorder, base URI) rather than reaching for globals inside. |
| 19 | + |
| 20 | +## When to Use |
| 21 | + |
| 22 | +- Starting a new API integration (step 0 of `generate-http-api-call`). |
| 23 | +- Adding authentication, logging, retries or a header to an existing integration. |
| 24 | +- Choosing between `JsonPreset`, `RawPreset` and friends, or composing a custom encoder/decoder. |
| 25 | + |
| 26 | +## The three classes |
| 27 | + |
| 28 | +```php |
| 29 | +<?php |
| 30 | + |
| 31 | +declare(strict_types=1); |
| 32 | + |
| 33 | +namespace App\Infrastructure\Bakery; |
| 34 | + |
| 35 | +use Psr\Log\LoggerInterface; |
| 36 | + |
| 37 | +final readonly class BakeryClientConfig |
| 38 | +{ |
| 39 | + public function __construct( |
| 40 | + public string $apiUri, |
| 41 | + #[\SensitiveParameter] |
| 42 | + public string $apiKey, |
| 43 | + public LoggerInterface $logger, |
| 44 | + public bool $debug = false, |
| 45 | + ) { |
| 46 | + } |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +```php |
| 51 | +<?php |
| 52 | + |
| 53 | +declare(strict_types=1); |
| 54 | + |
| 55 | +namespace App\Infrastructure\Bakery; |
| 56 | + |
| 57 | +use Phpro\HttpTools\Client\ClientBuilder; |
| 58 | +use Phpro\HttpTools\Formatter\FormatterBuilder; |
| 59 | +use Phpro\HttpTools\Formatter\RemoveSensitiveHeadersFormatter; |
| 60 | +use Psr\Http\Client\ClientInterface; |
| 61 | + |
| 62 | +final readonly class BakeryClientFactory |
| 63 | +{ |
| 64 | + public static function create(BakeryClientConfig $config): ClientInterface |
| 65 | + { |
| 66 | + return ClientBuilder::default() |
| 67 | + ->addBaseUri($config->apiUri) |
| 68 | + ->addHeaders(['X-Bakery-Key' => $config->apiKey]) |
| 69 | + ->addLogger( |
| 70 | + $config->logger, |
| 71 | + FormatterBuilder::default() |
| 72 | + ->withDebug($config->debug) |
| 73 | + ->withMaxBodyLength(1000) |
| 74 | + ->addDecorator(RemoveSensitiveHeadersFormatter::createDecorator([ |
| 75 | + 'X-Bakery-Key', |
| 76 | + ])) |
| 77 | + ->build(), |
| 78 | + ) |
| 79 | + ->build(); |
| 80 | + } |
| 81 | +} |
| 82 | +``` |
| 83 | + |
| 84 | +```php |
| 85 | +<?php |
| 86 | + |
| 87 | +declare(strict_types=1); |
| 88 | + |
| 89 | +namespace App\Infrastructure\Bakery; |
| 90 | + |
| 91 | +use Phpro\HttpTools\Client\ClientBuilder; |
| 92 | +use Phpro\HttpTools\Transport\Presets\JsonPreset; |
| 93 | +use Phpro\HttpTools\Transport\TransportInterface; |
| 94 | +use Phpro\HttpTools\Uri\TemplatedUriBuilder; |
| 95 | +use Psr\Http\Client\ClientInterface; |
| 96 | + |
| 97 | +final readonly class BakeryTransportFactory |
| 98 | +{ |
| 99 | + public function __construct( |
| 100 | + private BakeryClientConfig $config, |
| 101 | + ) { |
| 102 | + } |
| 103 | + |
| 104 | + /** |
| 105 | + * @return TransportInterface<array|null, array> |
| 106 | + */ |
| 107 | + public function create(?ClientInterface $client = null): TransportInterface |
| 108 | + { |
| 109 | + return new BakeryErrorHandlingTransport( |
| 110 | + JsonPreset::create( |
| 111 | + $client ?? BakeryClientFactory::create($this->config), |
| 112 | + new TemplatedUriBuilder(), |
| 113 | + ), |
| 114 | + ); |
| 115 | + } |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +The optional `?ClientInterface $client` argument is the seam that makes this factory usable from a test: production passes nothing, a test passes a mock or recording client and still gets the real preset and the real error-handling decorator. See `test-http-integration`. |
| 120 | + |
| 121 | +`ClientBuilder::default()` already installs `ErrorPlugin`, so 4xx/5xx throw `ClientErrorException`/`ServerErrorException`. Use the bare constructor `new ClientBuilder()` only when you deliberately want to inspect error responses yourself. |
| 122 | + |
| 123 | +## Plugin order matters |
| 124 | + |
| 125 | +`ClientBuilder` orders plugins by priority, highest first, so you don't have to think about array order: |
| 126 | + |
| 127 | +| Constant | Value | For | |
| 128 | +|---|---|---| |
| 129 | +| `ClientBuilder::PRIORITY_LEVEL_LOGGING` | 2000 | logging, recording — runs outermost, sees the final request | |
| 130 | +| `ClientBuilder::PRIORITY_LEVEL_SECURITY` | 1000 | authentication | |
| 131 | +| `ClientBuilder::PRIORITY_LEVEL_DEFAULT` | 0 | everything else | |
| 132 | + |
| 133 | +`addLogger()` and `addRecording()` default to logging priority; `addAuthentication()` defaults to security. Only pass an explicit `priority:` when you need something between the levels — for instance a plugin that must run *before* authentication signs the request. |
| 134 | + |
| 135 | +Getting this wrong is subtle: a logger at default priority logs the request *before* the auth plugin adds its header, so your logs and your cassettes won't match what went over the wire. |
| 136 | + |
| 137 | +## Builder methods |
| 138 | + |
| 139 | +| Method | Adds | |
| 140 | +|---|---| |
| 141 | +| `addBaseUri($uri, replaceHost: true)` | `BaseUriPlugin` — request models keep relative paths | |
| 142 | +| `addHeaders(['X-Key' => '…'])` | `HeaderSetPlugin` — static headers, API keys | |
| 143 | +| `addAuthentication($auth)` | `AuthenticationPlugin` — any `Http\Message\Authentication` (`BasicAuth`, `Bearer`, `Header`, `QueryParam`) | |
| 144 | +| `addLogger($logger, $formatter)` | `LoggerPlugin` | |
| 145 | +| `addRecording($namingStrategy, $recorder)` | VCR record + replay — for tests, see `test-http-integration` | |
| 146 | +| `addPlugin($plugin, priority: …)` | any HTTPlug plugin | |
| 147 | +| `addCallback(fn, priority: …)` | promotes a closure to a plugin, no class needed | |
| 148 | +| `addPluginWithCurrentlyConfiguredClient(fn)` | a plugin that needs to call the API itself (OAuth token fetch) | |
| 149 | +| `addDecorator(fn(ClientInterface): ClientInterface)` | wraps the client itself, not a plugin | |
| 150 | + |
| 151 | +Plugin catalogue, custom plugins, and the OAuth-token-refresh pattern: [references/plugins.md](references/plugins.md). |
| 152 | + |
| 153 | +**Before writing a plugin, check whether HTTPlug already has one** — retry, redirect, cookies, caching, decoding, history and more are [already available](http://docs.php-http.org/en/latest/plugins/). |
| 154 | + |
| 155 | +## Choosing a transport |
| 156 | + |
| 157 | +Presets pair an encoder with a decoder. Pick by what goes over the wire, not by what your models look like. |
| 158 | + |
| 159 | +| Preset | `TransportInterface<…>` | Use for | |
| 160 | +|---|---|---| |
| 161 | +| `JsonPreset` | `<array\|null, array>` | JSON APIs — the common case | |
| 162 | +| `FormUrlencodedPreset` | `<array\|null, array>` | `application/x-www-form-urlencoded` submissions | |
| 163 | +| `RawPreset` | `<string, string>` | XML, CSV, plain text | |
| 164 | +| `PsrPreset` | `<string, ResponseInterface>` | you need headers/status in the handler | |
| 165 | +| `BinaryDownloadPreset::withEmptyRequest` | `<null, BinaryFile>` | downloads | |
| 166 | +| `BinaryDownloadPreset::withMultiPartRequest` | `<MultiPart, BinaryFile>` | uploads returning a file | |
| 167 | + |
| 168 | +Mixing encodings (JSON out, raw in; multipart out, JSON in) means composing your own — the full encoder/decoder matrix is in [references/transports.md](references/transports.md). |
| 169 | + |
| 170 | +Pair a preset with `new TemplatedUriBuilder()` unless your request models return finished URIs, in which case use `RawUriBuilder::createWithAutodiscoveredPsrFactories()`. |
| 171 | + |
| 172 | +## Transport decorators |
| 173 | + |
| 174 | +API-wide behaviour that isn't HTTP-level belongs in a decorator around the preset, composed once in the transport factory: error envelopes returned under `200`, `application/problem+json` translation, unwrapping a `{"data": …}` envelope before handlers see it. |
| 175 | + |
| 176 | +A decorator implements `TransportInterface` and delegates — see `generate-http-request-handler` for a worked example. Handlers stay unchanged, and every endpoint inherits the behaviour. |
| 177 | + |
| 178 | +## Symfony wiring |
| 179 | + |
| 180 | +```yaml |
| 181 | +services: |
| 182 | + App\Infrastructure\Bakery\BakeryClientConfig: |
| 183 | + arguments: |
| 184 | + $apiUri: '%env(APP_BAKERY_API_URI)%' |
| 185 | + $apiKey: '%env(APP_BAKERY_API_KEY)%' |
| 186 | + $logger: '@monolog.logger.bakery' |
| 187 | + $debug: '%kernel.debug%' |
| 188 | + |
| 189 | + App\Infrastructure\Bakery\BakeryTransportFactory: ~ |
| 190 | + |
| 191 | + bakery.transport: |
| 192 | + class: Phpro\HttpTools\Transport\TransportInterface |
| 193 | + factory: ['@App\Infrastructure\Bakery\BakeryTransportFactory', 'create'] |
| 194 | + |
| 195 | + App\Infrastructure\Bakery\Order\: |
| 196 | + resource: '../src/Infrastructure/Bakery/Order/*/*RequestHandler.php' |
| 197 | + arguments: ['@bakery.transport'] |
| 198 | +``` |
| 199 | +
|
| 200 | +Credentials come from env vars, never from a constant or a committed config file. More on framework integration: `docs/framework/symfony.md`. |
| 201 | + |
| 202 | +## Common Mistakes |
| 203 | + |
| 204 | +| Mistake | Fix | |
| 205 | +|---|---| |
| 206 | +| Tests rebuild the plugin stack by hand | Give the factory an optional `?ClientInterface` and reuse it. | |
| 207 | +| A second transport factory for the same API | One per API; the plugin stacks will otherwise drift. | |
| 208 | +| Base URI hardcoded in request models' `uri()` | `addBaseUri()` on the client; keep `uri()` relative. | |
| 209 | +| API key logged in plaintext | `RemoveSensitiveHeadersFormatter` / `RemoveSensitiveJsonKeysFormatter` / `RemoveSensitiveQueryStringsFormatter` decorators on the formatter. | |
| 210 | +| Logger added at default priority | Leave it at `PRIORITY_LEVEL_LOGGING` so it sees the fully-built request. | |
| 211 | +| `new ClientBuilder()` then surprised 404s don't throw | Use `ClientBuilder::default()` for `ErrorPlugin`. | |
| 212 | +| Custom plugin for something HTTPlug ships | Check the plugin list first. | |
| 213 | +| Retry logic in a handler | `RetryPlugin` on the client. | |
| 214 | +| Client built inline inside a request handler | Handlers receive a `TransportInterface`, nothing else. | |
0 commit comments