Skip to main content
WritingVideosSpeakingCodeAbout

API Design

An Example App That Needs Its Features

Flagpole is a feature flag service built on kit, where every HTTP standard is there because the domain needs it. Building it found four bugs that kit's own tests never caught.

Oct 20266 min read

Most example apps are a to-do list with the interesting parts bolted on. Nobody polls a to-do list, so when one ships with ETags you learn that the framework supports them and nothing about when you would want them.

I wanted an example for kit, my Laravel API starter kit, where pulling out any one of its standards would break something a user would notice. RSVPs, invoicing, an uptime monitor and a reading list were all on the list and all fell short somewhere. Invoicing turns into a tutorial about tax, and a reading list needs almost none of it. A feature flag service needed everything, so that is what I built. It is called Flagpole, and it is public now.

Why a flag service

Every application using a flag service asks for its flags every few seconds, forever. That makes “nothing has changed” the most common answer it gives, so it had better be cheap. Flagpole sends an ETag with every evaluation and answers 304 with no body until something in that environment changes, and a change in production leaves the clients in staging alone.

Targeting rules are a shared document, and two people editing one at the same time is a genuine way to ship the wrong thing to your users. So every change has to carry the ETag it was based on, in If-Match. Leave it out and you get a 428. Send a stale one and you get a 412, and the second person finds out about the first instead of quietly overwriting them.

SDK keys end up in browsers, where anyone can read them. A Flagpole SDK key belongs to one environment and can evaluate that environment’s flags, and that is all it can do: it cannot read the rules behind them, change anything, or see another environment. Under the hood it is a Sanctum token owned by the environment rather than by a user, holding a single ability.

The deploy script that creates a flag for new code will, sooner or later, time out halfway through. When it retries with the same Idempotency-Key, it gets the first answer back rather than a second flag.

And every change leaves a trail: who made it, with which key, and the document before and after, sent on as a signed CloudEvent to anyone who subscribed.

No SDK

The first ADR in the record is the decision that Flagpole would have no SDK. Applications evaluate flags with OpenFeature and its OFREP provider, the Remote Evaluation Protocol that lets any OpenFeature provider ask any flag service what a flag is:

import { OpenFeature } from '@openfeature/web-sdk';
import { OFREPWebProvider } from '@openfeature/ofrep-web-provider';
await OpenFeature.setContext({ targetingKey: 'user-1', email: 'jane@flagpole.test' });
await OpenFeature.setProviderAndWait(new OFREPWebProvider({
baseUrl: 'http://127.0.0.1:8000',
headers: [['Authorization', 'Bearer <SDK key>']],
}));
const newCheckout = OpenFeature.getClient().getBooleanValue('new-checkout', false);

Speaking a protocol means every language with an OFREP provider works on day one, and there is no client library for me to maintain. The cost is that “works with OpenFeature” is a claim someone has to check. The contract tests validate Flagpole’s answers against OFREP’s own published OpenAPI document, and the repository has a script that runs OpenFeature’s real providers against the demo and fails on any wrong answer.

Following OFREP meant breaking kit’s rules in two places, on purpose. The evaluation routes answer errors in OFREP’s format instead of problem details, because that is all the providers read. And bulk evaluation answers a POST with a 304, which HTTP does not allow, because OFREP specifies it and the providers rely on it. Both are recorded as decisions, along with what they were weighed against, so nobody comes along later and “fixes” them.

Written down first

Flagpole was specified before any of it was built. The record lives in the repository, written with ArchDoc: an RFC for each area, an ADR for each decision with the alternatives it beat, and spec pages describing what exists now. The first RFC sets the scope, including what was left out and why. There is no web interface, no teams, no event streams, and Flagpole keeps no record of evaluations.

The commit history is meant to be read the same way. The first commit is kit exactly as laravel new --using=juststeveking/kit wrote it, and each commit after that adds one thing. If you want to know what building on a starter kit really involves, diff any point in the history against that first commit.

What it found

I did not expect this part. Kit has a lot of tests, a strict CI pipeline and a contract test for its own spec, and one real application on top of it turned up four problems that none of those had caught.

Browsers could not poll at all. Kit’s CORS settings allowed neither If-Match nor If-None-Match, and did not expose ETag, so a browser client could never read the tag it was supposed to send back. Every test passed, because no test runs in a browser.

On a nested route, a token without the right ability got a 404 where it should have got a 403, because Laravel bound the route parameters before Sanctum checked the ability. It was worse with idempotency in the mix: the refusal was stored, so retrying with a token that did have the ability just replayed the 403.

Also on nested routes, api-toolbox took the ETag from the wrong bound model. If-Match on a flag was being checked against the project it belongs to.

And a brand new application failed its secrets scan on its very first push. The scanner’s range started at the parent of the first commit, which a new repository does not have, and kit’s ignore file listed its false positives by commit hash, which an application’s history does not contain either.

There was a fifth, and that one was mine. Percentage rollouts hash the flag key with the user’s targeting key and read the first four bytes of the digest. Kit’s Pint and Rector rules rewrite substr to mb_substr, which counts characters rather than bytes, and on a raw binary digest that moved every user into a different bucket. A known-answer test with pinned values caught it before anything shipped, and the fix was to stop slicing the string at all and let unpack read the bytes.

The first four are fixed upstream now, in kit or api-toolbox, and Flagpole picked up the releases the same way any other application would. That is what an example app is for. Exercising the happy path proves a feature exists, and it takes a real application to find out whether the feature works.

Try it

Terminal window
git clone https://github.com/JustSteveKing/flagpole.git
cd flagpole
composer setup
php artisan db:seed
php artisan serve

The seeder creates a demo project with a flag of each type and prints an SDK key, and the getting started guide takes you from there to changing a flag and watching the answer change. It runs locally, and there is no hosted demo.

If you are weighing up kit for your next API, read Flagpole before you read kit’s feature list. It covers the same features, and it shows you why each one is there.

Related

More in API Design

All posts →

Same subject

In other formats

All of API Design →