Each endpoint is declared once, with a zod schema for what comes in and what goes out. From that one declaration you get three things: the handler Express serves, the OpenAPI JSON for your docs, and a TypeScript client for the frontend.
The constraint: the declaration is not a server. @panth977/routes only describes routes. Serving them is a separate handler, @panth977/routes-express for Express, or one you write for another framework.
I'm Panth, and I lead the software team at Oizom. @panth977/routes is one of my open-source libraries on JSR. This post walks through it with code: middleware, an HTTP route, an SSE route, and the calls that turn a folder of routes into docs and a client.
What one declaration gives you
| From the route's schemas | Through |
|---|---|
| A typed handler, served by Express |
ROUTES.execute + @panth977/routes-express (or your own handler) |
| OpenAPI JSON |
ROUTES.getRouteDocJson, built on zod-openapi
|
| A TypeScript client for the frontend | ROUTES.CodeGen.genTsCode |
Because every route is already declared with types, the docs don't need a second source. The OpenAPI JSON can serve a docs UI, and the same JSON generates the frontend's code. Nothing is written twice.
Routes are built the same way as @panth977/functions, so you keep its wrappers, context, static, name and namespace, build, and input/output schemas. The functions post covers those.
Middleware
A middleware declares its own request schema and its security rule. Both end up in the docs of every route that uses it.
const DecodedStateKey = FUNCTIONS.DefaultContextState.CreateKey<ReturnType<typeof decodeToken>>({ label: 'Decoded',scope: 'global' });
const authorize = ROUTES.Middleware.build({
security: {
JwtAuth: {
type: "apiKey", // this rules will be used for documentation of routes
description: "Authorize ** /users/login **.",
name: "x-auth-token",
in: "header",
},
},
request: ROUTES.z.MiddlewareRequest({ // this schema will be used to address route request schema, in documentation
headers: z.object({ "x-auth-token": z.string() }).passthrough(),
}),
response: ROUTES.z.MiddlewareResponse({}),
async func({context, headers}) {
const token =
headers["x-auth-token"] ??
headers["authorized"] ??
headers["x-token"];
const decoded = await decodeToken(token);
if (!decoded) throw createHttpError.Unauthorized("Token not found / Token got Expired / Invalid Token!");
// set state to context for others to make use of it!
context.useState(DecodedStateKey).set(decoded);
return {};
},
});
This one looks for the token in three headers (x-auth-token, then authorized, then x-token), decodes it, and throws a 401 if that fails. On success it stores the decoded token in the request's context under DecodedStateKey. Any route that lists authorize can read it back from there, which is what the next example does.
An HTTP route
const getProfile = ROUTES.Http.build([authorize], "get", "/profile", {
description: `Get user profile.`,
request: ROUTES.z.HttpRequest({
query: z.object({
getFriends: z.enum(['true', 'false']).default('false'),
})
}),
response: ROUTES.z.HttpResponse({
body: z.object({
id: z.number(),
username: z.string(),
address: z.string().nullable(),
noOfProjects: z.number().int().gt(0),
}),
}),
static: {
query: pg.query(`Select * from users where id = ? LIMIT 1`),
},
async func({context, query, build}) {
const { userId } = context.useState(DecodedStateKey).get();
const result = await build.query.execute([userId]);
if (!result.rows.length) throw createHttpError.NotFound("Given user was not found in db, could be because someone has deleted the user.");
if (query.getFriends === 'true') {...}
return result.rows[0];
},
});
The route reads the user id that authorize stored, runs a query declared once in static, and returns a row. The getFriends query parameter arrives as the string 'true' or 'false', with 'false' as the default, because that is what the schema says.
Compared with @panth977/functions, input becomes request and output becomes response. The request's path, query, headers and body arrive in func as named arguments. The response has headers and body.
ROUTES.z.HttpRequest and ROUTES.z.HttpResponse stand in for z.object so the types stay exact. You can still pass your own. For the docs, a route also takes description, tags, summary, security, reqMediaTypes and resMediaTypes.
A Server-Sent Events route
An SSE route is an async generator. This one yields the request's logs 30 at a time until there are none left.
export const getLogs = ROUTES.Sse.build([systemAuthorized], "get", "/logs/{requestId}", {
request: ROUTES.z.SseRequest({
path: z.object({
requestId: z.string(),
}),
}),
response: ROUTES.z.SseResponse(
z
.object({ id: z.number(), msg: z.string(), ts: z.date() })
.array()
.transform((x) => JSON.stringify(x))
),
async *func({context, path: { requestId }}) {
let logs = [];
let offset = 0;
do {
const result = await pg.query(`SELECT * FROM logs where id = ? ORDER BY ts OFFSET ? LIMIT 30`, [requestId, offset]);
logs = result.rows
offset += logs.length;
yield logs;
} while (logs.length);
},
});
Chaining middleware instead
If you prefer app.get('/profile', …) with middleware chained in front, ROUTES.Endpoint gives you that shape.
const initMiddleware = ROUTES.Middleware.build(...);
const userAuthMiddleware = ROUTES.Middleware.build(...);
const thirdPartyAuthMiddleware = ROUTES.Middleware.build(...);
const EndpointFactory = ROUTES.Endpoint.build().addMiddleware(initMiddleware);
const endpoints = {
userAuthorized: EndpointFactory.addMiddleware(userAuthMiddleware),
thirdPartyAuthorized: EndpointFactory.addMiddleware(thirdPartyAuthMiddleware),
}
const getProfile = endpoints.userAuthorized.HTTP('get', '/profile', ...);
const getProfile3rdParty = endpoints.thirdPartyAuthorized.get('/api/{userId}/profile', ...);
From a folder of routes to OpenAPI
ROUTES.getEndpointsFromBundle takes a module's exports and keeps only the endpoints (HTTP and SSE), dropping helpers and middleware. The detailed types are lost at this step, which is what you want when you are serving or filtering every route at once.
// --- routes/index.ts ---
export * from './m.ts'; // middleware
export * from './a.ts'; // route1, route2, and a helper variable
export * from './b.ts'; // route3, route4 (SSE), and a helper function
// --- server.ts ---
import * as routes_ from './routes/index.ts';
const routes = ROUTES.getEndpointsFromBundle({bundle: routes_}); // strong type will be lost
console.log(routes) // {route1: ..., route2: ..., route3: ..., route4: ...}
Then the OpenAPI JSON, with routes tagged internal-apis left out:
const routes = ROUTES.getEndpointsFromBundle({
bundle: await import('./routes/index.ts'),
excludeTags: ['internal-apis'],
});
const OpenApiJson = ROUTES.getRouteDocJson(routes, {
info: {
title: 'My Apis',
version: '0.0.1',
}
});
Serving a route
ROUTES.execute runs one endpoint through lifecycle hooks you supply. This is the seam where a framework plugs in. The example uses Express; routes-express is the full reference handler.
type MyFrameworkOpt = { req: Express.Request, res: Express.Response };
const MyEndpointExecutionLifeCycle: ROUTES.LifeCycle<MyFrameworkOpt> = {
init(...) {...},
onStatusChange({...}) {...},
onExecution({...}) {...},
onResponse({...}) {...},
onComplete({...}) {...},
}
app.get('/getUser', (req, res) => {
FUNCTIONS.DefaultContext.Builder.forTask(null, async (context, done) => {
ROUTES
.execute({
context,
build: getUserEndpoint,
lc: MyEndpointExecutionLifeCycle,
opt: { req, res },
})
.finally(done);
})
})
The TypeScript client
The last output comes from the OpenAPI JSON. ROUTES.CodeGen turns it into frontend code with a typed call for every API. Here it is served from an endpoint:
app.get('/code-gen', (_, res) => {
FUNCTIONS.DefaultContext.Builder.forTask('CodeGen', function (context, done) {
res.on('finish', done);
try {
const { code } = ROUTES.CodeGen.genTsCode({ context, input: { json, options: {} } });
res.setHeader('content-type', 'text/x.typescript').send(code);
} catch (err) {
console.log(err);
res.status(500).send('Something went wrong!');
}
});
});
The schema is written once, and the handler, the docs and the client are all read from it.
The rest of the packages are at jsr.io/@panth977.
Where do your API docs come from today: the route code itself, or a spec someone keeps in step by hand?
Top comments (0)