DEV Community

Cover image for One zod schema per route: Express handler, OpenAPI and TS client
Panth Patel
Panth Patel

Posted on Originally published at panth.vardayinitech.in

One zod schema per route: Express handler, OpenAPI and TS client

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 {};
  },
});
Enter fullscreen mode Exit fullscreen mode

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];
  },
});
Enter fullscreen mode Exit fullscreen mode

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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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', ...);
Enter fullscreen mode Exit fullscreen mode

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: ...}
Enter fullscreen mode Exit fullscreen mode

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',
    }
});
Enter fullscreen mode Exit fullscreen mode

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);
    })
})
Enter fullscreen mode Exit fullscreen mode

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!');
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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)