@vibedout/api-contract
v0.2.1
Published
What a feature contributes to the public APIs without importing the feature that owns them: for servers (`/api/v1`, secret keys and scopes) the shape of an operation (method, path, scope, Zod schemas for the query, the path, the body and the answer, and a
Maintainers
Readme
@vibedout/api-contract
What a feature adds to the two public APIs of a vibedout app without importing the feature that owns them:
/api/v1, for servers (a secretvsk_key with scopes), owned by theconnectorsfeature:defineApi, slotworker-api./api/store, for a storefront on another origin (a publishablepk_key with allowed origins, or a server's secretsk_key that a browser never gets served; no scopes, a guest buyer), owned by thestorefeature:defineStoreApi, slotworker-store-api.
Hono and Zod are peer dependencies (Zod at run time only in openapi, Hono at run time only in testing); no runtime dependency.
The owners are the router, the key check, the rate limit (connectors for /api/v1; store for /api/store, by address and by key, with Retry-After), the OpenAPI 3.1 document and the usage counts of keys. A Worker block may not import another block, and a feature must work when
the owner is not installed, so a feature that wants its own reads and writes in an API describes them here and lets the generated Worker barrel hand them over.
// src/worker/blocks/catalog/api.ts
import { defineApi, operationFor } from "@vibedout/api-contract";
import { z } from "zod";
import type { AppEnv } from "../../env";
const listProducts = operationFor<AppEnv>()({
id: "listProducts",
method: "get",
path: "/products", // under /api/v1; /products/{id} for a path parameter
scope: "catalog:read", // one of the scopes below
summary: "The published products, newest first",
query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(24) }),
response: z.object({ data: z.array(z.object({ id: z.string() })) }),
async handler({ c, caller, query }) { // the context, who is calling (never the key's secret), and the input, already checked
return { data: await readProducts(c.env.DB, query.limit) };
},
});
export const catalogApi = defineApi<AppEnv>({
id: "catalog",
scopes: [{ id: "catalog:read", description: { en: "Read the published products", "es-MX": "Leer los productos publicados" } }],
operations: [listProducts],
});// block.json
"packages": [{ "name": "@vibedout/api-contract", "range": "^0.2.0" }],
"wiring": { "worker-api": [{ "file": "src/worker/blocks/catalog/api.ts", "export": "catalogApi", "scopes": ["catalog:read"] }] }The engine writes registerApi(catalogApi) into src/worker/blocks.gen.ts and refuses two features that declare one scope. connectors reads the registry
inside a request (apiContributions()): it authenticates the key, finds the operation, checks the scope, validates the query, the path and the body with
the feature's schemas, calls the handler, and writes the operation into its OpenAPI document from the same schemas.
The storefront API (defineStoreApi)
// src/worker/blocks/checkout/store-api.ts
import { defineStoreApi, storeOperationFor } from "@vibedout/api-contract";
const op = storeOperationFor<AppEnv>();
const getOrder = op({
id: "getOrder", method: "get", path: "/orders/{orderId}", summary: "One order",
proof: { id: "order", param: "orderId" }, // the buyer must show the order's token (header x-order-token) FOR THIS orderId
params: z.object({ orderId: z.string() }),
response: orderView, // a Zod schema with an .meta({ id }) is a named component of the document and a type of the SDK
async handler({ c, params, proof }) { /* ... */ },
});
export const checkoutStoreApi = defineStoreApi<AppEnv>({
id: "checkout",
proofs: [{ id: "order", header: "x-order-token", description: { en: "...", "es-MX": "..." }, verify: ({ token, subject }) => /* the subject proved, or undefined */ }],
operations: [getOrder],
});// block.json
"wiring": { "worker-store-api": [{ "file": "src/worker/blocks/checkout/store-api.ts", "export": "checkoutStoreApi" }] }- No scopes. The publishable key is the whole permission, so a storefront operation can read what a shop shows anyone and write what a guest may write (a cart, a checkout). Anything more is a server operation.
- A proof is a header the buyer holds for a thing of theirs (a cart, an order). The feature that makes the thing declares the proof (
proofs, withverify) and the operation asks for it by id; another feature may ask for a proof it did not declare (the fulfilment tracking asks fororder). An operation that asks for a proof no installed feature declares is left out of the router and the document.verifyanswers the subject it proves orundefined, never throws, and does not look at the database to say no, so "no such order" and "not your order" read the same. - Who is asking (
caller):keyId,keyTitle,channelId,channelHandle,kind(publishableorsecret) andallowsUrl(url), which says whether an address is at an origin the key lists (the owner's one rule for an origin). A contribution that sends a buyer somewhere (a payment's return page) asksallowsUrland never reads the key's origins itself. - The owner's order of checks (the
storefeature'sapi.ts): the rate limit by address, the key and the origin, the operation (404), the proof (401<id>_token_missing/<id>_token_invalid), the path, query, headers and body against the operation's schemas (400), the handler. Each answer isno-storeunless the handler says otherwise. registerStoreApirefuses (a Worker that would answer half an API does not start) an invalid contribution, a duplicate id and two operations a request could mean both of; a feature's own test callscheckStoreContribution(checkoutStoreApi)and expects[].
Refusals (errors)
An operation lists the refusals it makes of its own, so the document says them and a typed client can branch on them (50.17-T52):
const buyNow = op({
id: "buyNow", /* ... */
errors: [
{ status: 403, code: "forbidden", reason: "guest_checkout_off", description: "The shop takes no orders from a storefront right now." },
{ status: 409, code: "conflict", reason: "sold_out", description: "That is sold out." },
{ status: 404, code: "not_found", description: "There is no such thing to buy." }, // a refusal with no `reason` is fine
],
handler(...) { throw new AppError(409, "conflict", "...", { reason: "sold_out" }); },
});status goes with its envelope code (ERROR_STATUS_CODES: 400 bad_request or validation_failed, 401, 403, 404, 409, 429, 500 and 502 internal_error), a reason is lowercase words with _ (the word in details.reason), a refusal is listed once, and checkContribution / checkStoreContribution say what is wrong. operationObject writes one response for each status: the error envelope as its body, the refusals in words and, for a program, in x-vibedout-errors ([{ code, reason?, description }]); the owner's own answers (the input's 400, a path's 404, the key's 401/403/429) are written the same way with errorResponses(errors, into), which adds to a response the owner already wrote and says a refusal it already lists in the operation's own words. The handler is not checked against its list (like response); a test of the operation makes each refusal happen. The generated client (packages/store-sdk) has refusalOf(operationId, error) that returns the documented refusal.
The documents
openapi writes OpenAPI 3.1 pieces from the same Zod schemas the handlers validate with, so a document cannot promise what the code does not check: SchemaComponents collects schemas with an id as named components (#/components/schemas/Order), schemaOf writes one (JSON Schema 2020-12; Zod 4.4 and 4.5 differ in how they write a root id, both are handled),
operationObject writes an operation (parameters from the path, query and header schemas, the body, the responses, the security), normalizeSchema tidies what a client generator trips on (additionalProperties: false, the safe-integer bounds Zod adds to every integer, a pattern that repeats a format, and anyOf [T, null] written as type: [T, "null"]), and apiErrorSchema is the error envelope both APIs use. Both owners use these helpers, so the two documents have one dialect;
packages/store-sdk in the vibedout repository holds the committed reference documents of both APIs and a typed TypeScript client generated from them.
Testing an operation
import { exerciseOperation } from "@vibedout/api-contract/testing";
const { status, answer, problems } = await exerciseOperation(checkoutStoreApi, "getOrder", { env, params: { orderId }, proof: { id: "order", subject: orderId } });
expect(problems).toEqual([]); // the answer is what the operation's `response` schema (and so the document) says, field for fieldIt runs the handler inside a real Hono request, checks the test's input with the operation's own schemas and compares the answer with the response schema; it does not do the owner's work (no key, no scope, no proof, no router). testing is not in the barrel: a Worker's bundle never pulls it in.
Modules
| Module | What it is |
|---|---|
| contract | ApiContribution, ApiOperation, ApiScopeDef, ApiCaller, OperationError, ERROR_CODES, ERROR_STATUS_CODES, defineApi, defineOperation, operationFor<Env>() (an operation whose handler sees the app's bindings), the shapes of an id, a scope and an operation id |
| path | /products/{id} paths: pathProblem, compilePath, matchPath (percent-decoded parameters, one trailing slash allowed), pathsOverlap |
| registry | registerApi (throws ApiContractError for a contribution that is not valid or clashes with one registered), apiContributions, checkContribution (what a feature's own test calls), clearApiContributions (tests). The registry is kept on the isolate's global object (Symbol.for("@vibedout/api-contract.contributions")), so two copies of the package in one Worker share it |
| store | StoreApiContribution, StoreOperation, StoreProof, defineStoreApi, storeOperationFor<Env>(), the shapes of a proof id and header |
| store-registry | registerStoreApi, storeApiContributions, storeProofsOf, checkStoreContribution, clearStoreApiContributions (the registry is on the isolate's global object under its own symbol) |
| operations | the checks both kinds of operation share (operationProblems, operationClashes, errorProblems): an id, a method, a body only where the method has one, a path that reads, a schema for every {name}, no two operations a request could mean both of, refusals that make sense |
| body | readJsonBody(request, maxBytes): a JSON body (application/json only, so a cross-site form post is refused) read within a ceiling that is enforced while streaming; throws BodyError for the owner to turn into its envelope |
| openapi | SchemaComponents, schemaOf, parametersOf, operationObject, errorResponses, normalizeSchema, apiErrorSchema |
| testing | exerciseOperation, answerProblems (test files only; @vibedout/api-contract/testing) |
registerApi is checked on purpose: a Worker that would answer with half an API, or let two features answer one path, does not start. A feature's own
test calls checkContribution(catalogApi) and expects [].
Where it is tested
pnpm --filter @vibedout/api-contract test (70 tests): the paths (what is a path, what matches, an escaped slash), the registry (shared by two copies of the module, sorted, replaced by
id, every thing checkContribution names, clashes of scopes, operation ids and paths between features), the types a handler is given, the storefront registry (proofs, clashes, the helpers), the OpenAPI helpers (named components, Zod 4.4 and 4.5, formats), the JSON body reader, the testing helper, and the
manifest (MIT, public, Hono and Zod as peers only, every module a subpath with a build entry, testing outside the barrel).
Versions
- 0.2.1 (
50.17-T52,T49,T38):errorson an operation (OperationError,errorResponses,errorProblems; the document'sx-vibedout-errors), andStoreCaller.kindandStoreCaller.allowsUrl. Additive: a contribution written for 0.2.0 works, and the blocks keep asking^0.2.0(the engine refuses two ranges of one package among features added together); one that uses what is new needs 0.2.1 or later, which is the first one that will be published. - 0.2.0: the storefront contract (
defineStoreApi, buyer proofs), the OpenAPI helpers both owners share, the JSON body reader andexerciseOperation. - 0.1.0: the contract and registry of what a feature adds to
/api/v1.
