Skip to content

HTTP contracts

Typed route, query, body, and response descriptors per handler - validated at the edge before your code runs.

AI generated, pending review Updated 10 days ago · Flare 0.3

A contract is a per-handler descriptor of route segments, query parameters, request body, and optional response shapes. For each matching request, Flare parses what you declared, then hands typed values to your handler.

Headers are not part of the contract. Read inbound headers from ctx.req.headers. Only route, query, body, response, maxBodyBytes, and signedCookies are descriptor fields.

The alternative this replaces is validation the handler carries itself: a parse call repeated at the top of every handler, or a validation middleware whose result type never reaches the handler, so the handler re-asserts through a cast what was checked one layer up. The check and the annotation are then maintained separately, and when one changes the cast still compiles; the mismatch surfaces as a wrong value inside a handler instead of a 400 at the boundary. A descriptor is one declaration doing both jobs: the parse run at the edge and the static type of scope.input derive from the same fields, so a handler reading route.id as a number is itself the proof the check ran.

StyleHowRead values
Inline routeDescriptor fields on HttpRouteOptions (route, query, body, …)scope.input
Controllerstatic contract = httpContract({ methodName: descriptor, … })this.ctx.extract(Contract.methodName)

You cannot mix inline descriptor fields and a branded contract token on the same route.

import { httpContract } from "@flare-ts/core";
const ApiContract = httpContract({
getUser: { route: { id: int }, response: { 200: UserSchema } },
createUser: { body: UserSchema, response: { 201: UserSchema } },
});

Keys must match controller handler method names. Extra keys with no handler emit an ORPHANED_CONTRACT_ENTRY warning at build().

match route
→ parse route params (400 on failure)
→ parse query params (400 on failure)
→ attach stream body when body uses streaming
→ before middleware
→ buffer + JSON validate body (400/413 on failure)
→ handler
→ response serializer (when declared)

Route and query validation run before before middleware. JSON body validation runs after before, immediately before the handler.

Contract validation failures return 400 or 413. They never enter host.http.error dispatch. See Error handling.

KeyValidates
routeNamed :id / :slug segments (int, str only at the HTTP edge)
queryQuery-string primitives (str, int, bool, date, arrays, optional)
bodymodel(), schema(...), or stream marker
responsePer-status serializers; strips undeclared fields
maxBodyBytesPer-route body cap; overrides global default
signedCookiesOpt into build-time cookies.secret check

Schema primitives come from @flare-ts/lib/schema. The stream marker imports from @flare-ts/core.

import { FlareHost, FlareResponse } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
import { int, optional, str } from "@flare-ts/lib/schema";
const host = new FlareHost(node);
host.http.get(
"/users/:id",
{ route: { id: int }, query: { include: optional(str) } },
(_ctx, scope) => {
const { route, query } = scope.input;
return new FlareResponse(200, {
id: route.id,
include: query.include ?? null,
});
},
);
const app = host.build();
app.run();
import { ControllerBase, httpContract } from "@flare-ts/core";
import { Get, Post } from "@flare-ts/core/decorators";
import { int, model, str } from "@flare-ts/lib/schema";
class CreateUser extends model({ name: str.min(1), email: str }) {}
const ApiContract = httpContract({
getUser: { route: { id: int } },
createUser: { body: CreateUser },
});
class ApiController extends ControllerBase {
public static override deps = [];
public static override state = [];
public static override contract = ApiContract;
@Get("/users/:id")
getUser() {
const { route } = this.ctx.extract(ApiContract.getUser);
return this.ok({ id: route.id });
}
@Post("/users")
createUser() {
const { body } = this.ctx.extract(ApiContract.createUser);
return this.ok({ id: 1, name: body.name });
}
}