Skip to main content
WritingVideosSpeakingCodeAbout

API Design

Both Ends of the Wire

I built a small CRM API on api-toolbox and its SDK on sdk-toolbox, in one repository, tested against each other. My own two libraries disagreed within a day.

Oct 20268 min read

Over the last couple of weeks I’ve written about both halves of the same idea. In Most of Your API Has Already Been Designed I argued that an API shouldn’t invent its own error format, retry header or pagination envelope, and I built api-toolbox so a Laravel API doesn’t have to. Then in Most of Your SDK Has Already Been Designed, Too I went round to the client’s side and built sdk-toolbox, so a PHP SDK actually listens to those standards.

Both of those articles lean on the same claim, and neither of them tests it. The claim is that an API and a client written to the same standards need no private agreement between them. If the server answers a stale write with a 412, the client can explain it. If the server sends a Retry-After, the client waits that long, and when the server signs a webhook, the client can check the signature without knowing anything about the server beyond the RFC it signed with.

Easy to say. Much harder to show, because each toolbox has its own test suite, and each suite only knows its own end of the wire. So I built something that has both.

Pingback

Pingback is a small CRM API and the PHP SDK that calls it, in one repository. The domain is Inertia’s demo app, Ping CRM, taken API-first: organisations and contacts in an account, an export of the contacts, and webhooks for every change. I picked a CRM because it’s boring. Nobody needs a CRM explained to them, so all of the attention goes on the HTTP.

The API lives in api/ and is made from Kit, my Laravel starter built on api-toolbox. The SDK lives in sdk/, on sdk-toolbox. The part that actually matters, though, is the SDK’s contract suite, which runs the SDK against the real, running API:

Terminal window
PINGBACK_BASE_URI=http://127.0.0.1:8000/v1 composer test:contract

On every push, CI boots the API from scratch, seeds it, starts a queue worker and runs that suite against it. With no API running, the same tests replay the responses they recorded, so the SDK’s everyday test run stays fast and offline. If either end changes something the other one doesn’t follow, the build goes red on that same commit.

One change, both ends

The clearest way I know to show what “the same standards” buys you is a lost update. Two people open the same organisation and both change it. The second save was made against a version that doesn’t exist anymore, and it quietly wipes out the first, with no error, no failed request, nothing to say the first edit was ever there. Ping CRM updates with a plain PUT and nothing to stop that from happening, which is fine for a demo and exactly the failure a real CRM can least afford.

In Pingback, the SDK reads an organisation and keeps the ETag it came with:

$organization = $pingback->organizations()->get($id);
$organization = $pingback->organizations()->update(
$organization,
$organization->with(city: 'Leeds'),
);

with() makes a changed copy, and the update sends a merge patch of only what differs, under the ETag from that read. This is what the contract suite recorded going over the wire:

PATCH /v1/organizations/01m63sykrncece9mg8ym9gzqqd
Content-Type: application/merge-patch+json
If-Match: "383b9f194b6075f34cfd12c007aea9c6"
{"city":"Leeds"}

Over on the API, the whole contract sits in the route’s middleware:

Route::patch('/organizations/{organization}', UpdateOrganizationController::class)
->whereUlid('organization')
->middleware(['abilities:organizations:write', 'consumes:application/merge-patch+json', 'merge-patch', 'conditional', 'preconditions.required'])
->name('v1.organizations.update');

A request without If-Match gets a 428, and one with a stale If-Match gets a 412. The patch is applied to the current record and it’s the merged document that gets validated, because in a merge patch null means “remove this”, and validation rules written for the resource would read that the wrong way round.

Now send the same change again with the original ETag, the way a second person working from the same read would:

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{"type":"https://apiguide.dev/errors/precondition-failed","title":"Precondition Failed","status":412,"detail":"The resource has changed since you last fetched it. Fetch it again and retry with its current ETag."}

The SDK hands you a PreconditionFailedException carrying that problem, and the city stays Leeds. Nobody’s change disappeared, and at no point did either end run code that knows about the other one. The API speaks RFC 9110 and RFC 9457, the SDK reads RFC 9110 and RFC 9457, and that’s the entire agreement.

The same goes for everything else Pingback leans on: Link headers for paging, the Idempotency-Key draft for creates, and webhooks signed with RFC 9421 and a Content-Digest. The README walks this one update all the way through, right up to the signed organization.updated webhook it sends and the SDK verifying it on arrival.

Where they disagreed

This is the bit I didn’t expect, and it’s the reason I’m glad I built the thing at all.

The contact export is asynchronous. You ask for it with Prefer: respond-async from RFC 7240, the API answers 202 Accepted with a status monitor in Location and a Retry-After, and once the export’s finished, the monitor answers 303 See Other pointing at the CSV. api-toolbox implements all of that. sdk-toolbox has an await() that follows it. Both have tests, and both pass.

They didn’t agree.

api-toolbox’s monitor answers 200 while the export is still running, with the status in the body and a Retry-After saying when to ask again. That’s a perfectly reasonable reading of it, because the GET on the monitor itself succeeded. sdk-toolbox’s await(), though, only kept polling while it saw a 202. So against my own API, my own SDK would have stopped at the very first poll, taken the status document, and handed it to code that was expecting a CSV.

What makes it sting a little is that sdk-toolbox’s documentation said, in so many words, that await() handles the pattern api-toolbox emits. I wrote that sentence. Each library’s tests checked it against my idea of the other one, and my idea was wrong.

The fix went into sdk-toolbox rather than Pingback, because that’s where the gap actually was. A 200 that carries Retry-After now means “still running, ask again then”, and a monitor that reports a failed operation throws an OperationFailedException instead of reaching your result code. It went out as sdk-toolbox 0.0.4.

That wasn’t the only thing the contract suite turned up, either:

  • A queued job only knows APP_URL. The export job built the file’s URL with route(), and in a queue worker there’s no request to read the host from. The client had called 127.0.0.1, and the 303 sent it to localhost. The URL is built in the request now and handed to the job.
  • An HTTP client that follows redirects on its own hides them from you. Symfony’s PSR-18 client follows by default, so the monitor’s 303 never reached await() at all, and because the redirect changed host, the client dropped the token on the way. The contract suite now uses a client that leaves redirects to the SDK, and the SDK copes either way.
  • A webhook event nobody could subscribe to. The API sent export.completed, but a subscription asking for it got refused, because the event never made it into the list of types. The API’s own test built its subscription with a factory, which skips the validation a real client goes through. The SDK’s contract test came in through the front door and found it straight away.

None of these is exotic, and that’s sort of my point. Every one of them passed the tests on its own side of the wire.

What I’d take from it

If you maintain an API and an SDK for it, the most useful test you can write is the one that runs the real client against the real server. A mock shaped like your memory of the server won’t catch any of the above, and neither will the API’s feature tests calling it through Laravel’s own test client. The real SDK, over real HTTP, against the thing you’re going to deploy, will.

It doesn’t need to be anything elaborate. Pingback’s suite talks to a live API when PINGBACK_BASE_URI is set. With SDK_FIXTURES=record it also writes every exchange to a fixture, with the credentials redacted. Without an API it replays those fixtures, so the everyday run stays quick. CI does the live run on every push, which means a fixture can never quietly drift away from what the API actually sends.

The other thing I’d take from it is a bit more uncomfortable. Writing both ends to the same standards is necessary, and on its own it isn’t enough. The RFCs leave real decisions open, and what a status monitor answers while it’s busy is one of them, so two careful implementations can make different choices that are each perfectly defensible. The standards get you most of the way to “no private agreement”. The rest comes from a test that puts both ends in the same room.

Pingback is on GitHub: boot the API, run the contract suite against it, and then try changing one end without the other.

Related

More in API Design

All posts →

Same subject

In other formats

All of API Design →