Skip to Content
SDK & PluginsContract HTTP

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:

FieldTypeDescription
clientHTTP clientDefault client for all contracts in this instance
securitysecurity scheme"bearer", "basic", { type: "apiKey", ... }, { type: "oauth2", ... }, or null for public
tagsstring[]Merged additively with contract + case tags
featurestringDefault feature grouping key
extensionsExtensions (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

FieldTypeRequiredDescription
endpointstringYesHTTP method + path, e.g. "POST /users"
descriptionstringNoWhat this endpoint does, in business language
featurestringNoGroups contracts in projection output (e.g. "User Registration")
requestRequestSpecNoRequest spec — schema shorthand or { body, contentType, headers, example, examples }
tagsstring[]NoInherited by all cases
deprecatedstringNoMark entire endpoint deprecated with reason; propagates to all cases
extensionsExtensions (see below)NoOpenAPI 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

FieldTypeRequiredDescription
descriptionstringYesWhy this case exists — business behavior, not HTTP details
expectobjectYesSee expect below
bodyobject / fn / FormData / URLSearchParamsNoRequest body. Can be a function of logical input: (input) => ({...})
contentTypestringNoOverride request body serialization (default: "application/json")
headersobject / fnNoRequest headers. Can be a function of logical input
pathParamsRecord<string, ParamValue> / fnNoURL path parameters — fill the endpoint’s :key segments. Can be a function of logical input
paramsNoDeprecated alias of pathParams (same type, same behavior). Setting both on one case is a construction-time error
queryRecord<string, ParamValue>NoQuery string parameters
clientHTTP clientNoOverride instance’s client (useful for auth testing)
verifyfnNoCustom assertions after response: async (ctx, response) => void
needsSchemaLikeNoLogical input schema for function-valued action fields
givenstringNoWorld-state precondition this case assumes
verifyRulesarrayNoProjectable description of opaque verify() logic
runnabilityobjectNoRunner gates such as { requireAttachment: true } or { requireSession: true }
deferredstringNoMark case as not yet runnable, with reason
deprecatedstringNoMark case as retained but no longer executed
severity"critical" | "warning" | "info"NoAlert routing hint (default: "warning")
requires"headless" | "browser" | "out-of-band"NoPhysical capability required (default: "headless")
defaultRun"always" | "opt-in"NoWhether case runs automatically (default: "always")
tagsstring[]NoCase-specific tags (merged with contract-level tags)
extensionsExtensions (see below)NoOpenAPI 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:

FieldTypeDescription
statusnumberExpected HTTP status code (required)
schemaSchemaLikeZod/Valibot schema for response body
headersSchemaLike<NormalizedHeaders>Schema for response headers (see header validation)
contentTypestringExpected response content-type (defaults to "application/json")
exampleTSingle response example for OpenAPI docs (shorthand for examples: { default: { value } })
examplesRecord<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 needscontract.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’s needs at 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 with pathParams: ({ projectId }) => ({ projectId }).
  • ctx.cleanup(fn) registers teardown that runs in LIFO order after the case. ctx.http, ctx.vars, and ctx.secrets are 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 typeBody inputNotes
"application/json"plain objectDefault
"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 / Uint8ArrayPassed 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 formats

The 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.json
glubean contracts --projection public-openapi glubean contracts --projection all

When --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] from request.headers schema
  • Emits parameters[in=path|query] with per-param schema/description from ParamValue
  • Emits operation.deprecated + x-deprecated-reason for deprecated contracts
  • Carries extensions through as x-* operation fields
  • Derives securitySchemes from instance-level security declarations

Description guidelines

Case descriptions should use business language, not HTTP terminology:

BadGood
POST creates a userValid registration creates user and returns profile
Returns 409Already registered email is rejected
Validates request bodyMissing 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 / response description fields
  • 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

Last updated on