Skip to content

Commit 1ce422b

Browse files
committed
Add agent skills for building integrations
Introduces `.agents/skills/` — skills that teach a coding agent how to build an integration with this package, aimed at consumers rather than contributors. Six task-oriented skills, each usable on its own: * `generate-http-api-call` — orchestrates a full endpoint slice * `configure-http-client` — config, client and transport factory, once per API * `generate-http-request` — request models, URI templates, BodyType * `generate-http-response` — strictly typed response models * `generate-http-request-handler` — handler + interface, error handling layers * `test-http-integration` — mock client vs VCR cassettes Reference files cover the plugin catalogue and priorities, the encoder/decoder matrix, psl/type usage and VCR cassette handling. All examples use an imaginary "Crumbs Bakery" API so snippets compose across skills. `.claude/skills` is git-ignored so Claude Code can pick these up through a local symlink.
1 parent a532b2d commit 1ce422b

12 files changed

Lines changed: 1926 additions & 0 deletions

File tree

‎.agents/skills/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Agent skills for phpro/http-tools
2+
3+
Skills that teach a coding agent how to build integrations with this library. They live in
4+
`.agents/skills/` (the cross-runtime location: Claude Code, Codex, Copilot CLI and Gemini CLI all
5+
read it, some via a `.claude/skills` symlink which is git-ignored here).
6+
7+
They are written for the **consumer** of this package — someone integrating a third-party API in
8+
their own application — not for contributors to the library itself.
9+
10+
## Skills
11+
12+
| Skill | Use when |
13+
|---|---|
14+
| [`generate-http-api-call`](generate-http-api-call/SKILL.md) | Integrating a whole endpoint: request + response + handler + tests. Orchestrates the four below. |
15+
| [`configure-http-client`](configure-http-client/SKILL.md) | Client and transport setup: base URI, auth, logging, plugins, preset choice. Once per API. |
16+
| [`generate-http-request`](generate-http-request/SKILL.md) | Writing a `RequestInterface` model: URI templates, parameters, body. |
17+
| [`generate-http-response`](generate-http-response/SKILL.md) | Turning a decoded payload into a strictly typed value object. |
18+
| [`generate-http-request-handler`](generate-http-request-handler/SKILL.md) | The class that runs one call, and where error handling belongs. |
19+
| [`test-http-integration`](test-http-integration/SKILL.md) | Mock client vs VCR cassettes, and what each layer should assert. |
20+
21+
Each is usable on its own — ask for just a response model and you get just that skill.
22+
23+
## The conventions they encode
24+
25+
1. **One vertical slice per endpoint**, grouped per endpoint on disk — not `Model/`, `Request/`, `RequestHandler/` folders.
26+
2. **A handler interface for every endpoint**, so consumers mock one call instead of an API client.
27+
3. **Strictly typed response models, validated once** at the boundary. Never a stored raw array with `?? null` accessors. `psl/type` is the recommended validator, not a requirement.
28+
4. **Client and transport configured once per API**, in a factory the tests reuse — so the tested plugin stack is the production one.
29+
5. **Error handling at the layer that owns it**: plugin → transport decorator → handler, in that order of preference.
30+
6. **Handler tests run through the real transport** against a recorded cassette; mock clients are for plugins, encoders and error paths.
31+
32+
## Keeping them honest
33+
34+
The examples use an imaginary "Crumbs Bakery" API (`App\Infrastructure\Bakery`, `GET /orders/{orderId}`)
35+
throughout, so snippets compose across skills. Every library API they reference exists on this branch —
36+
when the library changes, update the skills alongside it.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)