Documentation
Contract Routes
Contracts let you describe an endpoint once — schema, metadata, handler — and Kynetra FX derives runtime validation, TypeScript types, OpenAPI spec, and the typed client automatically.
What is a contract?#
A contract is a route definition created with app.route(). Unlike a plain handler registered with app.get() or app.post(), a contract carries structured metadata: request and response schemas, OpenAPI annotations, and a strongly-typed handler. From this single source the framework derives everything downstream — no duplication required.
Here is the minimal shape of a contract. Only method, path, and handler are required; everything else is optional but unlocks additional capabilities.
import { app } from '@kynetra/fx' app.route({ method: 'GET', path: '/hello', operationId: 'getHello', summary: 'Return a greeting', handler(ctx) { return ctx.json({ message: 'Hello, world!' }) },})Adding operationId and summary costs nothing at runtime but gives the OpenAPI generator named operations and human-readable labels.
Validating the request body#
Pass an fx schema to the input option to validate the request body. Kynetra FX runs .parse() before your handler is called. If validation fails the framework returns a 422 Unprocessable Entity with a structured issues array — your handler never executes. When validation passes, ctx.input is fully typed according to the schema you supplied.
import { app, fx, Infer } from '@kynetra/fx' const CreateUserInput = fx.object({ name: fx.string().min(1).max(100), email: fx.string().min(5).max(255), age: fx.number().min(0).max(120),}) // TypeScript infers: { name: string; email: string; age: number }type CreateUserBody = Infer<typeof CreateUserInput> app.route({ method: 'POST', path: '/users', operationId: 'createUser', summary: 'Create a new user', input: CreateUserInput, handler(ctx) { // ctx.input is fully typed — no casting needed const { name, email, age } = ctx.input const user = db.insertUser({ name, email, age }) return ctx.json(user, { status: 201 }) },})Note
input validation applies to the parsed request body. For GET and other methods that carry no body, use query instead.See Validation for the full rules around how issues are formatted and how to customise 422 responses.
Validating query parameters#
The query option validates the URL query string. Validated values appear as ctx.validatedQuery, typed to match the schema. This works for every HTTP method, including GET.
import { app, fx } from '@kynetra/fx' const SearchQuery = fx.object({ q: fx.string().min(1).max(200), page: fx.optional(fx.number().min(1)), limit: fx.optional(fx.number().min(1).max(100)),}) app.route({ method: 'GET', path: '/search', operationId: 'search', summary: 'Full-text search with pagination', query: SearchQuery, handler(ctx) { // ctx.validatedQuery is typed: { q: string; page?: number; limit?: number } const { q, page = 1, limit = 20 } = ctx.validatedQuery const results = index.search(q, { page, limit }) return ctx.json(results) },})Tip
input and query on the same contract. Both are validated independently and their types do not intersect.Response typing#
The output option accepts an fx schema that describes the successful response body. Kynetra FX does not validate outgoing responses at runtime — output is used purely for documentation and client types. The OpenAPI generator includes it as the 200 response schema, and the typed client uses it to type the return value of each call.
import { app, fx } from '@kynetra/fx' const ProductSchema = fx.object({ id: fx.string(), name: fx.string(), price: fx.number(), category: fx.enum(['electronics', 'clothing', 'books']),}) app.route({ method: 'GET', path: '/products/:id', operationId: 'getProduct', summary: 'Fetch a single product by ID', output: ProductSchema, handler(ctx) { const product = db.getProduct(ctx.params.id) if (!product) return ctx.json({ error: 'Not found' }, { status: 404 }) return ctx.json(product) },})See OpenAPI generation for how output, tags, summary, and description map to the generated spec.
Coexisting with plain handlers#
Contracts are additive. You do not need to migrate every route to app.route() at once. Plain app.get(), app.post(), and friends continue to work exactly as before alongside contract routes.
import { app, fx } from '@kynetra/fx' // Plain handler — no schema, no metadataapp.get('/ping', (ctx) => ctx.json({ pong: true })) // Contract route — fully typed, validated, documentedapp.route({ method: 'POST', path: '/orders', operationId: 'createOrder', input: fx.object({ productId: fx.string(), quantity: fx.number().min(1), }), handler(ctx) { return ctx.json({ orderId: createOrder(ctx.input) }, { status: 201 }) },}) // Another plain handler for a health checkapp.get('/healthz', (ctx) => ctx.text('ok'))Only routes registered with app.route() appear in app.contracts() and in the generated OpenAPI spec. Plain handlers remain invisible to those APIs but handle requests normally.
Inspecting contracts#
app.contracts() returns an array of ContractMetadata objects — one for each route registered with app.route(). You can iterate this list to build custom tooling: documentation renderers, mock servers, API clients, or validation test suites.
import { app } from '../src/index' const contracts = app.contracts() for (const contract of contracts) { const hasInput = contract.input !== undefined const hasQuery = contract.query !== undefined console.log( `${contract.method.padEnd(7)} ${contract.path.padEnd(40)}` + ` op=${contract.operationId ?? '—'}` + ` body=${hasInput} query=${hasQuery}` )}The metadata array is built from the same configuration objects you pass to app.route(), so every field — tags, summary, description, and the raw schemas — is available on each entry.
OpenAPI and the typed client#
Two higher-level features are built directly on top of app.contracts():
- OpenAPI generation —
generateOpenAPI(app)walks every contract and emits a complete OpenAPI 3.1 document. Input, query, and output schemas are converted via.jsonSchema(); annotations (operationId,tags,summary,description) map directly to operation fields. See OpenAPI generation. - Typed client —
createFXClient(url)returns a client object whose methods match every contract exactly: parameter types come frominputandquery; the return type comes fromoutput. See Typed client.
Tip
Contract config reference#
All fields accepted by app.route():
| Name | Type | Description |
|---|---|---|
| method | string | HTTP method. One of 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'. Required. |
| path | string | Route path. Supports :param named segments and * wildcards. Required. |
| input | Schema | Optional body schema. Validated before the handler runs on POST/PUT/PATCH requests. Typed as ctx.input inside the handler. |
| query | Schema | Optional query-string schema. Validated for all methods. Typed as ctx.validatedQuery inside the handler. |
| output | Schema | Optional response schema. Not validated at runtime. Used for OpenAPI response documentation and typed-client return types. |
| operationId | string | Optional OpenAPI operationId. Should be unique across all routes. Becomes the method name on the typed client. |
| tags | string[] | Optional array of OpenAPI tag names. Used to group operations in generated docs and tooling like Swagger UI. |
| summary | string | Optional short OpenAPI summary line shown in generated docs. |
| description | string | Optional longer OpenAPI description for the operation. Supports Markdown in most OpenAPI renderers. |
| handler | (ctx) => Response | The route handler function. Receives a typed context object with ctx.input, ctx.validatedQuery, ctx.params, and response helpers. Required. |