Contract HTTP
Use contract.http.with() to declare endpoint behavior as named cases. Each case becomes a runnable test — no test() wrapper needed.
When to use
- You want to define what an API should do before implementing it
- You want structured, scannable specs that tools can extract without running code
- You want one declaration to cover success, error, and edge cases for a single endpoint
- You want a single source of truth that produces runnable tests and OpenAPI documentation
Basic contract
import { contract, configure } from "@glubean/sdk";
import { z } from "zod";
const { http: api } = configure({
http: { prefixUrl: "{{BASE_URL}}" },
});
// Create a scoped instance. Every contract shares the same client, security, tags.
const publicApi = contract.http.with("public", {
client: api,
security: null,
tags: ["users"],
});
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// @contract
export const createUser = publicApi("create-user", {
endpoint: "POST /users",
feature: "User Registration",
description: "Create a new user account",
cases: {
success: {
description: "Valid registration creates user and returns profile",
body: { email: "test@example.com", password: "secure123" },
expect: { status: 201, schema: UserSchema },
},
duplicateEmail: {
description: "Already registered email is rejected",
body: { email: "existing@example.com", password: "secure123" },
expect: { status: 409 },
},
missingPassword: {
description: "Missing required field returns validation error",
body: { email: "test@example.com" },
expect: { status: 400 },
},
},
});Each case key (success, duplicateEmail, missingPassword) becomes a test with ID create-user.success, etc.
The // @contract marker above the export is required for VSCode CodeLens. It has no runtime effect — it’s a UI hint for the editor.
Scoped instances — contract.http.with()
contract.http.with(name, defaults) binds a client, security scheme, and tags to a reusable factory. Direct contract.http("id", spec) is not supported — all contracts must go through an instance.
// One instance per auth boundary is the common pattern
const publicApi = contract.http.with("public", { client: publicHttp, security: null });
const userApi = contract.http.with("user", { client: api, security: "bearer" });
const adminApi = contract.http.with("admin", { client: adminHttp, security: "bearer" });Instance defaults:
| Field | Type | Description |
|---|---|---|
client | HTTP client | Default client for all contracts in this instance |
security | security scheme | "bearer", "basic", { type: "apiKey", ... }, { type: "oauth2", ... }, or null for public |
tags | string[] | Merged additively with contract + case tags |
feature | string | Default feature grouping key |
extensions | Extensions (see below) | OpenAPI x-* extensions inherited by all contracts |
security describes the contract for projection output such as OpenAPI and Specifications. It does not add runtime credentials by itself. Runtime auth still belongs in the client you pass to .with() or a case-level client override.
Instances can be chained: userApi.with("scoped", { ... }) creates a nested instance where tags and extensions merge.
Contract-level fields
| Field | Type | Required | Description |
|---|---|---|---|
endpoint | string | Yes | HTTP method + path, e.g. "POST /users" |
description | string | No | What this endpoint does, in business language |
feature | string | No | Groups contracts in projection output (e.g. "User Registration") |
request | RequestSpec | No | Request spec — schema shorthand or { body, contentType, headers, example, examples } |
tags | string[] | No | Inherited by all cases |
deprecated | string | No | Mark entire endpoint deprecated with reason; propagates to all cases |
extensions | Extensions (see below) | No | OpenAPI x-* extensions at the operation level |
Per-case client is still allowed and overrides the instance’s client. client is never set at the spec level — it belongs on the .with() instance.
Case fields
| Field | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Why this case exists — business behavior, not HTTP details |
expect | object | Yes | See expect below |
body | object / fn / FormData / URLSearchParams | No | Request body. Can be a function of logical input: (input) => ({...}) |
contentType | string | No | Override request body serialization (default: "application/json") |
headers | object / fn | No | Request headers. Can be a function of logical input |
pathParams | Record<string, ParamValue> / fn | No | URL path parameters — fill the endpoint’s :key segments. Can be a function of logical input |
params | — | No | Deprecated alias of pathParams (same type, same behavior). Setting both on one case is a construction-time error |
query | Record<string, ParamValue> | No | Query string parameters |
client | HTTP client | No | Override instance’s client (useful for auth testing) |
verify | fn | No | Custom assertions after response: async (ctx, response) => void |
needs | SchemaLike | No | Logical input schema for function-valued action fields |
given | string | No | World-state precondition this case assumes |
verifyRules | array | No | Projectable description of opaque verify() logic |
runnability | object | No | Runner gates such as { requireAttachment: true } or { requireSession: true } |
deferred | string | No | Mark case as not yet runnable, with reason |
deprecated | string | No | Mark case as retained but no longer executed |
severity | "critical" | "warning" | "info" | No | Alert routing hint (default: "warning") |
requires | "headless" | "browser" | "out-of-band" | No | Physical capability required (default: "headless") |
defaultRun | "always" | "opt-in" | No | Whether case runs automatically (default: "always") |
tags | string[] | No | Case-specific tags (merged with contract-level tags) |
extensions | Extensions (see below) | No | OpenAPI x-* extensions on the case, merged over contract + instance |
Contract cases are semantic — they do not own setup/teardown. Use given to describe preconditions, needs to declare logical input, and satisfy those inputs with contract.bootstrap() overlays, defineSession(), workflow().call(..., { in }), or explicit runner input.
expect
The expect object declares the expected response:
| Field | Type | Description |
|---|---|---|
status | number | Expected HTTP status code (required) |
schema | SchemaLike | Zod/Valibot schema for response body |
headers | SchemaLike<NormalizedHeaders> | Schema for response headers (see header validation) |
contentType | string | Expected response content-type (defaults to "application/json") |
example | T | Single response example for OpenAPI docs (shorthand for examples: { default: { value } }) |
examples | Record<string, { value, summary?, description? }> | Named response examples for OpenAPI docs |
Schema validation
Use expect.schema with a Zod schema to validate response body shape:
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// @contract
export const getUser = userApi("get-user", {
endpoint: "GET /users/:id",
cases: {
found: {
description: "Returns full user profile by ID",
pathParams: { id: "user-123" },
expect: { status: 200, schema: UserSchema },
},
},
});Response header validation
Use expect.headers to validate response headers. Header names are normalized to lowercase before validation (HTTP spec: header names are case-insensitive), so your schema uses lowercase keys. Multi-value headers like Set-Cookie come through as string[].
success: {
description: "Successful request returns content-type and request id",
expect: {
status: 200,
schema: UserSchema,
headers: z.object({
"content-type": z.string().regex(/^application\/json/),
"x-request-id": z.string().uuid(),
"set-cookie": z.array(z.string()).optional(),
}),
},
},Failed header validation emits a contract:failure event with kind: "schema". The headers schema also surfaces in the OpenAPI spec under responses[status].headers.
Response examples
expect.example (single) and expect.examples (named) populate the OpenAPI content.examples section. Examples do not run at test time — they’re purely for documentation.
success: {
description: "Returns user profile",
expect: {
status: 200,
schema: UserSchema,
example: { id: "u_1", email: "alice@example.com", createdAt: "2026-01-01T00:00:00Z" },
},
},
multiRole: {
description: "Role dictates returned profile shape",
expect: {
status: 200,
schema: UserSchema,
examples: {
admin: { value: { id: "u_1", role: "admin" }, summary: "Admin user" },
viewer: { value: { id: "u_2", role: "viewer" } },
},
},
},When multiple cases share a status code, all their examples merge into the OpenAPI response, keyed by case name to avoid collisions.
Custom verification
Use verify for assertions beyond status, schema, and headers:
cases: {
success: {
description: "Created user has correct email",
body: { email: "new@example.com", password: "secure123" },
expect: { status: 201, schema: UserSchema },
verify: async (ctx, user) => {
// `user` is the schema-parsed body (typed)
ctx.expect(user.email).toBe("new@example.com");
ctx.expect(user.id).toBeDefined();
},
verifyRules: [
{ description: "Created user keeps the submitted email address" },
{ description: "Created user has a stable server-generated ID" },
],
},
}verify() is executable code, so projection tools cannot fully infer its business meaning. Add verifyRules whenever the assertion is important for reviews, Cloud, or future agents.
Logical input with defineHttpCase
Use defineHttpCase<Needs>() when a case declares needs and has function-valued action fields. It type-locks needs to body, headers, pathParams, and query.
import { defineHttpCase } from "@glubean/sdk";
const authorized = defineHttpCase<{ token: string }>({
description: "Valid bearer token returns caller profile",
needs: z.object({ token: z.string() }),
headers: ({ token }) => ({ Authorization: `Bearer ${token}` }),
expect: { status: 200, schema: ProfileSchema },
});
// @contract
export const getMe = userApi("get-me", {
endpoint: "GET /me",
cases: { authorized },
});Tradeoff: inline cases preserve more precise workflow().call(...).out body typing from expect.schema; defineHttpCase prioritizes input-shape drift prevention.
Satisfying needs — contract.bootstrap() overlays
A case that declares needs cannot run bare: the runner has no value to hand its
function-valued fields. A bootstrap overlay supplies that value. It runs
before the case, does whatever setup the input requires (log in, seed a record,
create a project), and returns a value matching the case’s needs schema.
Overlays live in a sibling *.bootstrap.ts file. The harness eager-loads every
*.bootstrap.ts in the project before discovery, so an overlay registers even
though nothing imports it. Colocation next to the .contract.ts is the convention:
contracts/
me.contract.ts # defineHttpCase<{ token }> + getMe (above)
me.bootstrap.ts # contract.bootstrap(getMe.case("authorized"), ...)// me.bootstrap.ts
import { contract } from "@glubean/sdk";
import { getMe } from "./me.contract.ts";
export const authorizedOverlay = contract.bootstrap(
getMe.case("authorized"),
async (ctx) => {
const { accessToken } = await ctx.http
.post("https://api.example.com/login", {
json: { username: "emilys", password: "emilyspass" },
})
.json<{ accessToken: string }>();
ctx.cleanup(async () => {
// optional teardown — runs LIFO after the case, on success or failure
await ctx.http.post("https://api.example.com/logout");
});
return { token: accessToken }; // must match the case's `needs` shape
},
);- The first argument is a case reference (
getMe.case("authorized")). The overlay’s return type is checked against that case’sneedsat compile time, and validated again at runtime before the case runs. - The returned value feeds every function-valued field of the case —
headers,body,pathParams,query. Need a project id in the URL? Return it here and read it withpathParams: ({ projectId }) => ({ projectId }). ctx.cleanup(fn)registers teardown that runs in LIFO order after the case.ctx.http,ctx.vars, andctx.secretsare available for setup.- One overlay per case. Registering a second for the same case is a load-time error.
For input that varies per run (a different account, a CI flag), use the structured
{ params, run } form. params is a schema; run receives the validated params:
export const authorizedOverlay = contract.bootstrap(
getMe.case("authorized"),
{
params: z.object({ username: z.string(), password: z.string() }),
run: async (ctx, { username, password }) => {
const { accessToken } = await ctx.http
.post("https://api.example.com/login", { json: { username, password } })
.json<{ accessToken: string }>();
return { token: accessToken };
},
},
);Supply params at run time with --bootstrap-json; {{VAR}} interpolates against
env vars and secrets first:
glubean run me.contract.ts --filter get-me.authorized \
--bootstrap-json '{"username":"{{TEST_USER}}","password":"{{TEST_PASS}}"}'The file must end in .bootstrap.ts — a dot, not a dash. The harness only
eager-loads that exact suffix, so me-bootstrap.ts silently never registers and
the case hard-errors with “declares needs but has no overlay”. For a one-off run
without the overlay, pass input directly with --input-json '<JSON>'; the two
flags are mutually exclusive.
given and runnability
Use given for semantic preconditions and runnability for execution gates:
const duplicateEmail = defineHttpCase<{ email: string }>({
description: "Already registered email is rejected",
given: "a user with this email already exists",
needs: z.object({ email: z.string().email() }),
body: ({ email }) => ({ email, password: "secure123" }),
expect: { status: 409, schema: ErrorSchema },
runnability: { requireAttachment: true },
});runnability.requireAttachment is a hard bare-run gate. The case must receive
input from a bootstrap overlay, explicit runner input, or a workflow in
mapping. Use --force-standalone only for author-debug, never CI.
runnability.requireSession requires project session state, usually produced by defineSession().
Inbound cases
Use inboundCase() for counterparty promises such as webhooks. Inbound cases are projected but not runnable as standalone tests; await them from workflow().poll().
import { inboundCase } from "@glubean/sdk";
const paymentIntentCreated = inboundCase({
description: "Payment provider posts a signed payment_intent.created event",
expect: {
bodySchema: PaymentIntentCreatedSchema,
signature: {
scheme: "stripe-v1",
header: "stripe-signature",
secretRef: "STRIPE_WEBHOOK_SECRET",
},
within: 60000,
},
});Deferred cases
Mark cases that can’t run yet (missing credentials, infrastructure):
cases: {
rateLimit: {
description: "Excessive requests are throttled",
deferred: "Rate limiting not deployed yet",
body: { email: "spam@example.com", password: "x" },
expect: { status: 429 },
},
}Deferred cases are skipped during execution and show as ⊘ in projection output.
Deprecated cases
Mark cases that are retained for history but no longer executed:
cases: {
legacyTokenRefresh: {
description: "Legacy token refresh endpoint was removed in API v2",
deprecated: "replaced by /auth/refresh in v2",
body: { token: "old" },
expect: { status: 400 },
},
}Deprecated cases are skipped at runtime (just like deferred) and appear in projection as 🚫. Case-level deprecated is runtime + projection only — it does not change the generated OpenAPI. To mark an endpoint deprecated in OpenAPI (deprecated: true + x-deprecated-reason), set deprecated at the contract level (below).
For entire endpoints, set deprecated at the contract level — it propagates to every case:
// @contract
export const legacyLookup = userApi("legacy-lookup", {
endpoint: "GET /v1/users",
deprecated: "replaced by GET /v2/users — will be removed Q3 2026",
cases: {
paged: { description: "Paged listing still works", expect: { status: 200 } },
search: { description: "Search still works via ?q=", expect: { status: 200 } },
},
});A case-level deprecated overrides the propagated contract-level value.
Severity
severity tells Cloud / alerting tools how to triage failures:
"critical"— failure triggers immediate alert (auth, permission, payment boundaries)"warning"— default, recorded but may not alert"info"— informational check, no alert on failure
Only set explicitly when the default is wrong:
success: {
description: "Valid credentials return auth token",
severity: "critical", // auth must work — paging oncall if it breaks
body: { username: "alice", password: "..." },
expect: { status: 200, schema: LoginSchema },
},
notFound: {
description: "Unknown product returns 404",
severity: "info", // informational — doesn't need alert
pathParams: { id: "99999" },
expect: { status: 404 },
},Per-case auth
Use per-case client overrides to test auth boundaries:
const userApi = contract.http.with("user", { client: api, security: "bearer" });
const publicApi = contract.http.with("public", { client: publicHttp, security: null });
// @contract
export const deleteUser = userApi("delete-user", {
endpoint: "DELETE /users/:id",
cases: {
success: {
description: "Admin can delete a user",
pathParams: { id: "user-123" },
expect: { status: 204 },
},
forbidden: {
description: "Regular user cannot delete others",
client: api, // overrides the admin client from the instance
pathParams: { id: "user-123" },
expect: { status: 403 },
},
unauthenticated: {
description: "Anonymous access is rejected",
client: publicHttp, // case-level override to public client
pathParams: { id: "user-123" },
expect: { status: 401 },
},
},
});Parameter schemas
By default pathParams and query accept Record<string, string> — simple key/value. When you need the OpenAPI spec to show the parameter’s type (UUID, enum, etc.), use the ParamValue object form:
success: {
description: "Fetches user by UUID",
pathParams: {
// String shorthand — no schema in OpenAPI output (defaults to string)
tenantId: "t_42",
// Object form — schema and description flow to OpenAPI
id: {
value: "550e8400-e29b-41d4-a716-446655440000",
schema: z.string().uuid(),
description: "User unique identifier",
},
},
query: {
include: {
value: "profile,settings",
description: "Comma-separated fields to include",
required: false,
},
legacy: {
value: "false",
deprecated: true,
},
},
expect: { status: 200, schema: UserSchema },
},The runtime only reads value for URL/query construction. schema, description, required, deprecated are docs-only and merge at field level across all cases — one case supplying description and another supplying schema both contribute.
Request content types
By default body is serialized as JSON. Use contentType on the case (or at the contract level via structured request) to dispatch serialization:
// Multipart form upload
avatarUpload: {
description: "User uploads avatar image",
contentType: "multipart/form-data",
body: { file: blob, caption: "Profile pic" }, // object → FormData
expect: { status: 200, schema: AvatarSchema },
},
// URL-encoded form post (legacy OAuth-style endpoints)
tokenExchange: {
description: "Exchange code for access token",
contentType: "application/x-www-form-urlencoded",
body: { grant_type: "authorization_code", code: "abc" },
expect: { status: 200 },
},Supported content types:
| Content type | Body input | Notes |
|---|---|---|
"application/json" | plain object | Default |
"multipart/form-data" | FormData or Record<string, string | Blob | File> | Browser-native FormData or Node 22+ |
"application/x-www-form-urlencoded" | URLSearchParams or plain object | |
"text/plain" / "application/octet-stream" | raw string / Uint8Array | Passed through |
If the whole contract uses a non-JSON content type, set it once at the contract level via structured request:
// @contract
export const uploadAvatar = userApi("upload-avatar", {
endpoint: "POST /users/:id/avatar",
request: {
contentType: "multipart/form-data",
body: AvatarRequestSchema,
example: { file: "(binary)", caption: "Team pic" },
},
cases: {
success: { description: "Upload succeeds", body: { ... }, expect: { status: 200 } },
oversized: { description: "File over 5MB rejected", body: { ... }, expect: { status: 413 } },
},
});Request form — shorthand vs structured
request accepts two shapes:
// Shorthand — bare SchemaLike, treated as JSON body
request: UserSchema,
// Structured — full RequestSpec object
request: {
body: UserSchema,
contentType: "application/json",
headers: z.object({ "x-client-version": z.string() }),
example: { name: "Alice", email: "alice@example.com" },
examples: {
valid: { value: { name: "Alice", email: "alice@example.com" } },
edge: { value: { name: "A".repeat(255), email: "a@b.c" }, summary: "Max-length name" },
},
},The structured form lets you attach request examples, header schema, and non-JSON content types.
OpenAPI extensions
For tool-interop metadata that doesn’t fit a standard OpenAPI field, use extensions. Keys must start with x- — TypeScript enforces this via a template literal type:
type Extensions = Record<`x-${string}`, unknown>Example usage on instance, contract, and case:
const adminApi = contract.http.with("admin", {
client: admin,
security: "bearer",
extensions: {
"x-owner": "platform-team",
"x-tier": "internal",
},
});
// @contract
export const listUsers = adminApi("admin-list", {
endpoint: "GET /admin/users",
description: "List all users (admin only)",
extensions: {
"x-rate-limit": "100/hour",
},
cases: {
success: {
description: "Paged response",
expect: { status: 200, schema: UserListSchema },
extensions: {
"x-example-org": "acme-corp",
},
},
},
});Merge precedence: instance defaults < contract. Instance- and contract-level extensions are emitted as x-* fields on the OpenAPI operation. Case-level extensions stay scoped to the case and are not merged onto the operation — put anything that must appear in the OpenAPI spec at the contract level.
Non-x- keys are rejected at the TypeScript level. Pick a namespace for internal-only keys (e.g. x-glubean-internal-*) and exclude them from public OpenAPI in your own tooling — there is no automatic distinction.
Projection
Run glubean contracts to see all contracts as a human-readable report:
glubean contracts # markdown outline (default)
glubean contracts --format md-outline # explicit markdown outline
glubean contracts --format json # machine-readable JSON
glubean contracts --format openapi --title "My API"
glubean contracts --format list-formats # built-in and plugin formatsThe output groups contracts by instanceName → feature and lists cases with descriptions and lifecycle markers:
⊘ deferred: <reason>— skipped, waiting for something⊘ deprecated: <reason>— retained for history🔴— critical severityℹ️— info severity
For repeatable outputs, declare named projections in glubean.yaml and generate them by name:
projections:
contracts:
public-openapi:
dir: .
format: openapi
title: Public API
output: artifacts/openapi.jsonglubean contracts --projection public-openapi
glubean contracts --projection allWhen --projection is used, each projection carries its own dir, format, title, and output; the direct --dir, --format, and --title flags are ignored.
OpenAPI generation
Generate an OpenAPI 3.1 spec from contract definitions with glubean contracts --format openapi, a named projection, the MCP tool glubean_openapi, or the SDK programmatically. The exporter:
- Emits
responses[status].content[contentType](multiple content types per status are supported) - Merges examples and response headers from all cases sharing a status
- Emits
parameters[in=header]fromrequest.headersschema - Emits
parameters[in=path|query]with per-param schema/description fromParamValue - Emits
operation.deprecated+x-deprecated-reasonfor deprecated contracts - Carries
extensionsthrough asx-*operation fields - Derives
securitySchemesfrom instance-levelsecuritydeclarations
Description guidelines
Case descriptions should use business language, not HTTP terminology:
| Bad | Good |
|---|---|
| POST creates a user | Valid registration creates user and returns profile |
| Returns 409 | Already registered email is rejected |
| Validates request body | Missing required field returns validation error |
glubean contracts warns about descriptions that start with HTTP methods, contain status codes, or use jargon like “endpoint”, “payload”, or “request body”.
Descriptions surface in:
- CLI projection output (for PMs)
- OpenAPI
summary/ responsedescriptionfields - Test runner output on failure
- AI agent context when consuming contracts
Take time on them — they’re the primary communication surface between code and humans.
Next
- Workflow — compose contract cases into lifecycles
- test() API
- Assertions