Kynetra FX

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.

src/routes/hello.ts
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.

src/routes/users.ts
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.

src/routes/search.ts
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

You can combine 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.

src/routes/products.ts
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.

src/index.ts
import { app, fx } from '@kynetra/fx'
 
// Plain handler — no schema, no metadata
app.get('/ping', (ctx) => ctx.json({ pong: true }))
 
// Contract route — fully typed, validated, documented
app.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 check
app.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.

scripts/list-routes.ts
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 generationgenerateOpenAPI(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 clientcreateFXClient(url) returns a client object whose methods match every contract exactly: parameter types come from input and query; the return type comes from output. See Typed client.

Tip

Because contracts are the single source of truth, the OpenAPI spec and the typed client always stay in sync with your actual route implementations — no code generation step required.

Contract config reference#

All fields accepted by app.route():

NameTypeDescription
methodstringHTTP method. One of 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'. Required.
pathstringRoute path. Supports :param named segments and * wildcards. Required.
inputSchemaOptional body schema. Validated before the handler runs on POST/PUT/PATCH requests. Typed as ctx.input inside the handler.
querySchemaOptional query-string schema. Validated for all methods. Typed as ctx.validatedQuery inside the handler.
outputSchemaOptional response schema. Not validated at runtime. Used for OpenAPI response documentation and typed-client return types.
operationIdstringOptional OpenAPI operationId. Should be unique across all routes. Becomes the method name on the typed client.
tagsstring[]Optional array of OpenAPI tag names. Used to group operations in generated docs and tooling like Swagger UI.
summarystringOptional short OpenAPI summary line shown in generated docs.
descriptionstringOptional longer OpenAPI description for the operation. Supports Markdown in most OpenAPI renderers.
handler(ctx) => ResponseThe route handler function. Receives a typed context object with ctx.input, ctx.validatedQuery, ctx.params, and response helpers. Required.
Contract Routes · Kynetra FX