| name | http-endpoint-add |
| description | Add a new HTTP endpoint to the FlightJar API — either a map/data endpoint under /api/ or an operational one like /healthz. Covers DI-via-parameter-injection, the bbox-validation pattern for spatial queries, snake_case JSON responses, the "feature disabled → return a structured payload, not 500" convention, and the `[Collection("SequentialApi")]` WebApplicationFactory tests. Use when the user asks to add a new endpoint ("add /api/X", "expose Y over HTTP", "add a reset/recompute POST for Z"). Don't use for WebSocket / SSE / streaming work. |
Adding an HTTP endpoint
Every HTTP endpoint in FlightJar is a minimal-API lambda in
dotnet/src/FlightJar.Api/Program.cs. Services are injected by
type via the lambda signature; query-string params bind by
matching parameter name. The global JSON policy is snake_case
for both property names and string enums, configured once via
ConfigureHttpJsonOptions — no [JsonPropertyName] attributes
anywhere.
Reach for external-client-add instead if the endpoint's job
is to proxy a third-party HTTP source — that skill wraps the
upstream in a typed client with caching + throttling, and the
endpoint on top becomes a one-liner.
Touchpoints
One endpoint typically spans three files:
dotnet/src/FlightJar.Api/Program.cs — the app.MapGet / MapPost lambda itself, plus (if the endpoint needs a service
not yet registered) a builder.Services.AddSingleton<…>()
line up top.
dotnet/tests/FlightJar.Api.Tests/ApiEndpointsTests.cs —
xUnit [Fact] / [Theory] covering at minimum: happy path,
400 on bad input, feature-disabled response shape.
CLAUDE.md — one bullet under ### HTTP endpoint surface
describing the endpoint. Keep it to one line unless the
semantics are load-bearing.
Add a fourth touchpoint only when the endpoint ships a new data
record: if the JSON response uses a record that didn't exist,
put it in the closest-fit namespace (FlightJar.Core.Stats,
FlightJar.Persistence.Notifications, …) rather than under
FlightJar.Api. Anonymous types are fine for one-off response
shapes and in fact are the norm.
Step 1 — Pick a route path + verb
| Verb | Use for |
|---|
GET | Reads. Snapshots, stats, reference-data queries, lookups. Must be idempotent. |
POST /api/X/reset or /recompute | Side-effect triggers with no request body. Existing examples: /api/coverage/reset, /api/blackspots/recompute. |
POST /api/X with JSON body | Replace-semantic config writes. Existing examples: /api/notifications/config, /api/watchlist. |
Paths are lowercase-snake under /api/…. The operational
endpoints (/healthz, /metrics) live at the root. Do not
invent a /api/v1/… prefix — the API is an internal contract
between the backend and app/static/*.js, and we version by
just shipping a matched pair.
Step 2 — Write the lambda
Read services by declaring them as lambda parameters; the minimal
API pipeline resolves them from the DI container. Query-string
params bind by name (case-sensitive, snake_case to match the
rest of the API). Anonymous types are fine for the response;
they'll serialise snake_case automatically.
app.MapGet("/api/your_thing", (
YourService svc, AppOptions opts, int? limit) =>
{
if (!opts.YourFeatureEnabled)
{
return Results.Json(new { enabled = false });
}
var n = Math.Clamp(limit ?? 50, 1, 500);
return Results.Json(new
{
enabled = true,
items = svc.Take(n),
});
});
When two services share the same type (e.g. two ILogger
specialisations) or the parameter would otherwise look
ambiguous, tag it with [Microsoft.AspNetCore.Mvc.FromServices]
— see how AirportsDb is pulled into /api/airports.
Async lambdas
Return async when the endpoint awaits something; the return
type should be IResult for consistency with the synchronous
ones. Results.Json(…) works for any object.
app.MapGet("/api/flight/{callsign}", async (
string callsign, AdsbdbClient adsbdb, CancellationToken ct) =>
{
var info = await adsbdb.LookupRouteAsync(callsign, ct);
return Results.Json(info is null
? new { callsign, origin = (string?)null, destination = (string?)null }
: new { callsign, origin = info.Origin, destination = info.Destination });
});
Accept CancellationToken ct whenever the handler awaits — the
minimal API pipeline passes through the request-aborted token so
the client's pan-mid-fetch drops propagate down into
HttpClient.SendAsync.
Step 3 — Validate inputs
Return Results.BadRequest(new { error = "…" }) for malformed
input. The body shape { "error": "human-readable message" } is
what the frontend's error toasts and Playwright assertions
expect.
Bbox validation
For spatial endpoints taking min_lat / max_lat / min_lon /
max_lon, use the existing helper pattern rather than repeating
the check:
static (double mnLat, double mxLat, double mnLon, double mxLon)? ReadBbox(
double? minLat, double? maxLat, double? minLon, double? maxLon)
{
if (minLat is not double mnLat || maxLat is not double mxLat
|| minLon is not double mnLon || maxLon is not double mxLon) return null;
if (mnLat < -90 || mnLat > 90 || mxLat < -90 || mxLat > 90
|| mnLon < -180 || mnLon > 180 || mxLon < -180 || mxLon > 180) return null;
return (mnLat, mxLat, mnLon, mxLon);
}
ReadBbox already exists in Program.cs above the OpenAIP
endpoints. Reuse it. Don't also validate "min < max" — the
antimeridian-wrap case (e.g. min_lon=170, max_lon=-170 for
a bbox straddling the dateline) is handled by AirportsDb.Bbox
and friends, and rejecting it would break Pacific views.
Cap limit params
Use Math.Clamp(limit ?? DEFAULT, 1, MAX) so a pathological
?limit=1000000 doesn't materialise a giant response. The
existing airports / navaids endpoints clamp to 5000.
Step 4 — Feature-disabled responses
A feature without its dependency (OPENAIP_API_KEY, LAT_REF,
BLACKSPOTS_ENABLED=1) must not 500. Return a structured
payload with an enabled: false flag plus empty collections so
the frontend can render an empty state without a console error.
This is a load-bearing convention — the tests enforce it.
app.MapGet("/api/your_thing", (YourWorker worker) =>
{
if (!worker.Enabled)
{
return Results.Json(new { enabled = false, cells = Array.Empty<object>() });
}
return Results.Json(new { enabled = true, cells = worker.Items });
});
Step 5 — Register the service (if needed)
If the handler needs a service that isn't already in DI, add it
near the other builder.Services.Add… calls in the top
third of Program.cs. Prefer AddSingleton — the app is
single-process and shared state is the norm. AddHttpClient<T>()
registers a typed client factory (use this when the service owns
an HttpClient).
builder.Services.AddSingleton<YourService>();
builder.Services.AddSingleton(sp => new YourService(
options.SomePath,
sp.GetRequiredService<ILogger<YourService>>()));
Step 6 — Tests
Add to dotnet/tests/FlightJar.Api.Tests/ApiEndpointsTests.cs.
The class is gated by [Collection("SequentialApi")] so tests
don't race on environment state — keep new tests inside the
existing class unless you have a reason to spin a fresh factory.
The fixture already sets BEAST_HOST to a dead address, so the
consumer is retrying in the background.
Minimum coverage:
[Fact]
public async Task YourThing_HappyPath()
{
var client = _factory.CreateClient();
var resp = await client.GetAsync("/api/your_thing?limit=5");
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.TryGetProperty("items", out var items));
Assert.Equal(JsonValueKind.Array, items.ValueKind);
}
[Theory]
[InlineData("/api/your_thing?limit=-1")]
[InlineData("/api/your_thing?limit=abc")]
public async Task YourThing_RejectsBadInput(string url)
{
var client = _factory.CreateClient();
var resp = await client.GetAsync(url);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task YourThing_DisabledReturnsEnabledFalse()
{
var client = _factory.CreateClient();
var resp = await client.GetAsync("/api/your_thing");
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("enabled").GetBoolean());
}
For endpoints that exercise real BEAST frames, look at
BeastReplayE2ETests for the pattern — it pumps synthetic
frames into the consumer before hitting /api/aircraft.
Step 7 — CLAUDE.md
Add one bullet to the ### HTTP endpoint surface block. Match
the existing compressed style: path, one sentence of what it
returns, any notable guards. Don't duplicate the request-param
docs — curious readers go to the source.
Step 8 — Verify
cd dotnet
dotnet format FlightJar.slnx --verify-no-changes
dotnet test tests/FlightJar.Api.Tests/
dotnet test FlightJar.slnx
Do not
- Do not return 500 for a disabled feature.
enabled: false is
the contract — callers including the frontend and tests
depend on it. A 500 shows up as a red error toast.
- Do not add
[JsonPropertyName] attributes or a
per-endpoint JsonSerializerOptions. The global
ConfigureHttpJsonOptions + snake_case naming policy owns the
wire format. Fighting it produces mixed casing and the
frontend breaks in subtle ways.
- Do not construct
HttpClient inside a handler. If you need
upstream HTTP, add a typed client via the external-client-add
skill and inject it.
- Do not do synchronous I/O inside the lambda. Reading a file,
blocking on a task, or running CPU-heavy work stalls Kestrel's
I/O thread. Push work into a
BackgroundService and serve
cached results.
- Do not log per-request at INFO unless the endpoint is truly
rare (admin reset, test dispatch).
/api/airports gets hit
on every pan and a chatty log will drown real events.
- Do not put auth / rate-limiting on the API. This is a LAN
service behind the Docker compose network; adding a
half-implemented token check would be worse than nothing. If
you think a real auth boundary is needed, raise it first.